diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..68474a9b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Integration tests environment example +# Copy this file to .env and configure to run integration tests locally. +# +# Set DB_CONNECTION to the database you want to test against. +# Tests in tests/Integration/Database will run against this connection. + +DB_CONNECTION=sqlite +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=testing +DB_USERNAME=root +DB_PASSWORD= diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml new file mode 100644 index 000000000..e0747f60c --- /dev/null +++ b/.github/workflows/databases.yml @@ -0,0 +1,287 @@ +name: databases + +on: + push: + pull_request: + +jobs: + mysql_8: + runs-on: ubuntu-latest + timeout-minutes: 5 + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: testing + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h localhost" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: phpswoole/swoole:6.0.2-php8.4 + + strategy: + fail-fast: true + + name: MySQL 8.0 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /root/.composer/cache + key: composer-8.4-${{ hashFiles('composer.lock') }} + restore-keys: composer-8.4- + + - name: Install dependencies + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute integration tests + env: + DB_CONNECTION: mysql + DB_HOST: mysql + DB_PORT: 3306 + DB_DATABASE: testing + DB_USERNAME: root + DB_PASSWORD: password + run: vendor/bin/phpunit tests/Integration/Database + + mysql_9: + runs-on: ubuntu-latest + timeout-minutes: 5 + + services: + mysql: + image: mysql:9.0 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: testing + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h localhost" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: phpswoole/swoole:6.0.2-php8.4 + + strategy: + fail-fast: true + + name: MySQL 9.0 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /root/.composer/cache + key: composer-8.4-${{ hashFiles('composer.lock') }} + restore-keys: composer-8.4- + + - name: Install dependencies + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute integration tests + env: + DB_CONNECTION: mysql + DB_HOST: mysql + DB_PORT: 3306 + DB_DATABASE: testing + DB_USERNAME: root + DB_PASSWORD: password + run: vendor/bin/phpunit tests/Integration/Database + + mariadb_10: + runs-on: ubuntu-latest + timeout-minutes: 5 + + services: + mariadb: + image: mariadb:10 + env: + MARIADB_ROOT_PASSWORD: password + MARIADB_DATABASE: testing + ports: + - 3306:3306 + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: phpswoole/swoole:6.0.2-php8.4 + + strategy: + fail-fast: true + + name: MariaDB 10 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /root/.composer/cache + key: composer-8.4-${{ hashFiles('composer.lock') }} + restore-keys: composer-8.4- + + - name: Install dependencies + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute integration tests + env: + DB_CONNECTION: mariadb + DB_HOST: mariadb + DB_PORT: 3306 + DB_DATABASE: testing + DB_USERNAME: root + DB_PASSWORD: password + run: vendor/bin/phpunit tests/Integration/Database + + pgsql_17: + runs-on: ubuntu-latest + timeout-minutes: 5 + + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: password + POSTGRES_DB: testing + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: phpswoole/swoole:6.0.2-php8.4 + + strategy: + fail-fast: true + + name: PostgreSQL 17 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /root/.composer/cache + key: composer-8.4-${{ hashFiles('composer.lock') }} + restore-keys: composer-8.4- + + - name: Install dependencies + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute integration tests + env: + DB_CONNECTION: pgsql + DB_HOST: postgres + DB_PORT: 5432 + DB_DATABASE: testing + DB_USERNAME: postgres + DB_PASSWORD: password + run: vendor/bin/phpunit tests/Integration/Database + + pgsql_18: + runs-on: ubuntu-latest + timeout-minutes: 5 + + services: + postgres: + image: postgres:18 + env: + POSTGRES_PASSWORD: password + POSTGRES_DB: testing + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: phpswoole/swoole:6.0.2-php8.4 + + strategy: + fail-fast: true + + name: PostgreSQL 18 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /root/.composer/cache + key: composer-8.4-${{ hashFiles('composer.lock') }} + restore-keys: composer-8.4- + + - name: Install dependencies + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute integration tests + env: + DB_CONNECTION: pgsql + DB_HOST: postgres + DB_PORT: 5432 + DB_DATABASE: testing + DB_USERNAME: postgres + DB_PASSWORD: password + run: vendor/bin/phpunit tests/Integration/Database + + sqlite: + runs-on: ubuntu-latest + timeout-minutes: 5 + + container: + image: phpswoole/swoole:6.0.2-php8.4 + + strategy: + fail-fast: true + + name: SQLite + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /root/.composer/cache + key: composer-8.4-${{ hashFiles('composer.lock') }} + restore-keys: composer-8.4- + + - name: Install dependencies + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute integration tests + env: + DB_CONNECTION: sqlite + run: vendor/bin/phpunit tests/Integration/Database/Sqlite diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 141560b5f..14c221ce1 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -34,4 +34,6 @@ jobs: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o - name: Execute static analysis - run: vendor/bin/phpstan --configuration="phpstan.neon.dist" --memory-limit=-1 + run: | + vendor/bin/phpstan --configuration="phpstan.neon.dist" --memory-limit=-1 + vendor/bin/phpstan --configuration="phpstan.types.neon.dist" --memory-limit=-1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aab39105b..908508f1e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /root/.composer/cache + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: | diff --git a/composer.json b/composer.json index 6535b1eba..52018434e 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "Workbench\\App\\": "src/testbench/workbench/app/", "Hypervel\\": "src/core/src/", "Hypervel\\ApiClient\\": "src/api-client/src/", + "Hypervel\\Contracts\\": "src/contracts/src/", "Hypervel\\Auth\\": "src/auth/src/", "Hypervel\\Broadcasting\\": "src/broadcasting/src/", "Hypervel\\Bus\\": "src/bus/src/", @@ -35,6 +36,7 @@ "Hypervel\\Console\\": "src/console/src/", "Hypervel\\Container\\": "src/container/src/", "Hypervel\\Cookie\\": "src/cookie/src/", + "Hypervel\\Database\\": "src/database/src/", "Hypervel\\Coroutine\\": "src/coroutine/src/", "Hypervel\\Devtool\\": "src/devtool/src/", "Hypervel\\Dispatcher\\": "src/dispatcher/src/", @@ -52,6 +54,8 @@ "Hypervel\\NestedSet\\": "src/nested-set/src/", "Hypervel\\Notifications\\": "src/notifications/src/", "Hypervel\\ObjectPool\\": "src/object-pool/src/", + "Hypervel\\Pagination\\": "src/pagination/src/", + "Hypervel\\Pool\\": "src/pool/src/", "Hypervel\\Process\\": "src/process/src/", "Hypervel\\Prompts\\": "src/prompts/src/", "Hypervel\\Queue\\": "src/queue/src/", @@ -60,7 +64,7 @@ "Hypervel\\Sanctum\\": "src/sanctum/src/", "Hypervel\\Session\\": "src/session/src/", "Hypervel\\Socialite\\": "src/socialite/src/", - "Hypervel\\Support\\": "src/support/src/", + "Hypervel\\Support\\": ["src/collections/src/", "src/conditionable/src/", "src/macroable/src/", "src/reflection/src/", "src/support/src/"], "Hypervel\\Telescope\\": "src/telescope/src/", "Hypervel\\Testbench\\": "src/testbench/src/", "Hypervel\\Translation\\": "src/translation/src/", @@ -78,15 +82,25 @@ "src/filesystem/src/Functions.php", "src/foundation/src/helpers.php", "src/prompts/src/helpers.php", + "src/reflection/src/helpers.php", "src/router/src/Functions.php", "src/session/src/Functions.php", + "src/collections/src/Functions.php", + "src/collections/src/helpers.php", "src/support/src/Functions.php", "src/support/src/helpers.php", "src/translation/src/Functions.php", "src/core/src/helpers.php" + ], + "classmap": [ + "src/core/class_map/Hyperf/Coroutine/Coroutine.php", + "src/core/class_map/Command/Concerns/Confirmable.php" ] }, "autoload-dev": { + "files": [ + "tests/Database/Laravel/stubs/MigrationCreatorFakeMigration.php" + ], "psr-4": { "Hypervel\\Tests\\": "tests/" }, @@ -103,6 +117,7 @@ "ext-pdo": "*", "composer-runtime-api": "^2.2", "brick/math": "^0.11|^0.12", + "doctrine/inflector": "^2.1", "dragonmantank/cron-expression": "^3.3.2", "egulias/email-validator": "^3.2.5|^4.0", "friendsofhyperf/command-signals": "~3.1.0", @@ -112,13 +127,12 @@ "hyperf/cache": "~3.1.0", "hyperf/command": "~3.1.0", "hyperf/config": "~3.1.0", - "hyperf/database-sqlite": "~3.1.0", - "hyperf/db-connection": "~3.1.0", "hyperf/dispatcher": "~3.1.0", "hyperf/engine": "^2.10", "hyperf/framework": "~3.1.0", "hyperf/http-server": "~3.1.0", "hyperf/memory": "~3.1.0", + "hyperf/paginator": "~3.1.0", "hyperf/process": "~3.1.0", "hyperf/resource": "~3.1.0", "hyperf/signal": "~3.1.0", @@ -137,9 +151,13 @@ "sentry/sentry": "^4.15", "symfony/error-handler": "^6.3", "symfony/mailer": "^6.2", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", "symfony/process": "^6.2", "symfony/uid": "^7.4", - "tijsverkoyen/css-to-inline-styles": "^2.2.5" + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "voku/portable-ascii": "^2.0" }, "replace": { "hypervel/api-client": "self.version", @@ -147,12 +165,16 @@ "hypervel/broadcasting": "self.version", "hypervel/bus": "self.version", "hypervel/cache": "self.version", + "hypervel/collections": "self.version", + "hypervel/conditionable": "self.version", "hypervel/config": "self.version", "hypervel/console": "self.version", "hypervel/container": "self.version", + "hypervel/contracts": "self.version", "hypervel/cookie": "self.version", "hypervel/core": "self.version", "hypervel/coroutine": "self.version", + "hypervel/database": "self.version", "hypervel/devtool": "self.version", "hypervel/dispatcher": "self.version", "hypervel/encryption": "self.version", @@ -165,14 +187,18 @@ "hypervel/http-client": "self.version", "hypervel/jwt": "self.version", "hypervel/log": "self.version", + "hypervel/macroable": "self.version", "hypervel/mail": "self.version", "hypervel/nested-set": "self.version", "hypervel/notifications": "self.version", "hypervel/object-pool": "self.version", + "hypervel/pagination": "self.version", + "hypervel/pool": "self.version", "hypervel/process": "self.version", "hypervel/prompts": "self.version", "hypervel/queue": "self.version", "hypervel/redis": "self.version", + "hypervel/reflection": "self.version", "hypervel/router": "self.version", "hypervel/session": "self.version", "hypervel/socialite": "self.version", @@ -232,6 +258,7 @@ "Hypervel\\Bus\\ConfigProvider", "Hypervel\\Cache\\ConfigProvider", "Hypervel\\Cookie\\ConfigProvider", + "Hypervel\\Database\\ConfigProvider", "Hypervel\\Config\\ConfigProvider", "Hypervel\\Console\\ConfigProvider", "Hypervel\\Devtool\\ConfigProvider", diff --git a/docs/porting-from-laravel.md b/docs/porting-from-laravel.md new file mode 100644 index 000000000..4fbed7296 --- /dev/null +++ b/docs/porting-from-laravel.md @@ -0,0 +1,143 @@ +# Porting from Laravel + +## Tests + +### Directory Structure + +Ported Laravel tests live in `tests/{PackageName}/Laravel/` subdirectories. This separation: +- Makes it easy to diff against Laravel's test suite to identify missing tests +- Keeps Hypervel-specific tests separate from compatibility tests +- Allows running Laravel-ported tests independently + +### Base Classes + +Two TestCase options: + +| Class | Use When | +|-------|----------| +| `Hypervel\Tests\TestCase` | Unit tests, mocks only, no container needed | +| `Hypervel\Testbench\TestCase` | Integration tests, needs container (facades like Date, Config) | + +Always call `parent::setUp()` in your setUp method. + +### Namespace Changes + +- Change `Illuminate\` to `Hypervel\` +- Change `namespace Illuminate\Tests\{Package}` to `namespace Hypervel\Tests\{Package}\Laravel` +- Add `declare(strict_types=1);` at the top of every file + +### Stricter Typing + +Hypervel uses stricter types than Laravel. This exposes incomplete test mocks that Laravel's loose typing silently accepts. + +**Model properties require type declarations:** +```php +// Laravel +protected $table = 'users'; +protected $fillable = ['name']; +public $timestamps = false; + +// Hypervel +protected ?string $table = 'users'; +protected array $fillable = ['name']; +public bool $timestamps = false; +``` + +**Mock return types must match:** +```php +// Laravel (loose - stdClass works) +$connection = m::mock(stdClass::class); + +// Hypervel (strict - use correct type) +$connection = m::mock(PDO::class); +$query = m::mock(QueryBuilder::class); +``` + +**Fluent methods need return values:** +```php +// Laravel (null return silently accepted) +$builder->shouldReceive('where')->with(...); + +// Hypervel (must return for chaining) +$builder->shouldReceive('where')->with(...)->andReturnSelf(); +``` + +**Mocking methods with `static` return type:** + +Methods like `newInstance()` have `static` return type, meaning they must return the same class (or subclass) as the object they're called on. Mockery creates proxy subclasses, so returning the parent class fails: + +```php +// FAILS - mock is Mockery_1_MyModel, returning MyModel fails static type +$this->related = m::mock(MyModel::class); +$this->related->shouldReceive('newInstance')->andReturn(new MyModel); + +// WORKS - use partial mock and andReturnSelf() +$this->related = m::mock(MyModel::class)->makePartial(); +$this->related->shouldReceive('newInstance')->andReturnSelf(); + +// Test attributes on the mock itself (partial mock has real Model behavior) +$result = $relation->getResults(); +$this->assertSame('taylor', $result->username); +``` + +This is a testing-only issue - the strict types are correct and an improvement. In production code, you never mock Models and call `newInstance()`. + +**When `andReturnSelf()` isn't enough:** + +If a test needs to verify distinct instances (e.g., `makeMany()` returns different objects), use a concrete test stub instead of mocks: + +```php +class EloquentHasManyRelatedStub extends Model +{ + public static bool $saveCalled = false; + + public function newInstance(mixed $attributes = [], mixed $exists = false): static + { + $instance = new static; + $instance->setRawAttributes((array) $attributes, true); + return $instance; + } + + public function save(array $options = []): bool + { + static::$saveCalled = true; + return true; + } +} + +// Test verifies real behavior, not mock expectations +$this->assertNotSame($instances[0], $instances[1]); +$this->assertFalse(EloquentHasManyRelatedStub::$saveCalled); +``` + +Concrete stubs are the correct approach here - they test actual behavior rather than just verifying mocks were called correctly. + +### Missing Dependencies + +Some test files reference classes defined in other test files. Laravel gets away with this due to test suite load order. Make tests self-contained by defining required classes locally: + +```php +// Add at bottom of test file if TestModel is used but not defined +class TestModel extends Model +{ +} +``` + +### Unsupported Features + +Remove tests for features Hypervel doesn't support: +- `SqlServerConnector` tests (no SQL Server support) + +### Quick Checklist + +1. Update namespace to `Hypervel\Tests\{Package}\Laravel` +2. Add `declare(strict_types=1);` +3. Change `Illuminate\` imports to `Hypervel\` +4. Choose correct base TestCase +5. Ensure `parent::setUp()` is called +6. Add type declarations to model properties +7. Fix mock types (PDO, QueryBuilder, Grammar, etc.) +8. Add `->andReturnSelf()` to chained method mocks +9. Define any missing helper classes locally +10. Remove tests for unsupported drivers/features +11. Run tests and fix any remaining type errors diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d86b0c032..28d407af6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -28,6 +28,88 @@ parameters: ignoreErrors: # Framework traits provided for userland - not used internally but intentionally available - '#Trait Hypervel\\[A-Za-z\\\\]+ is used zero times and is not analysed\.#' + + # Fluent class uses magic __get/__set/__call for dynamic properties and methods (ColumnDefinition, IndexDefinition, etc.) + - '#Access to an undefined property Hypervel\\Support\\Fluent::\$#' + - '#Access to an undefined property Hypervel\\Database\\Schema\\ColumnDefinition::\$#' + - '#Access to an undefined property Hypervel\\Database\\Schema\\IndexDefinition::\$#' + - '#Access to an undefined property Hypervel\\Database\\Schema\\ForeignKeyDefinition::\$#' + - '#Call to an undefined method Hypervel\\Support\\Fluent::#' + - '#Call to an undefined method Hypervel\\Database\\Schema\\ColumnDefinition::#' + - '#Call to an undefined method Hypervel\\Database\\Schema\\IndexDefinition::#' + - '#Call to an undefined method Hypervel\\Database\\Schema\\ForeignKeyDefinition::#' + - '#Access to an undefined property Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::\$#' + - '#Call to an undefined method Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::#' + + # 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::#' + - '#Call to an undefined method TPivotModel of Hypervel\\Database\\Eloquent\\Relations\\Pivot::#' + + # Container::getInstance() - PHPStan sees the interface as abstract but concrete implementation exists at runtime + - identifier: staticMethod.callToAbstract + path: src/database/* + + # Deep generic template limitations - PHPStan can't fully resolve complex generic type relationships + - identifier: generics.lessTypes + path: src/database/* + - identifier: generics.notSubtype + path: src/database/* + - identifier: method.templateTypeNotInParameter + path: src/database/* + + # 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. + - identifier: generics.variance + + # Eloquent Relation subclass method overrides - covariance/contravariance issues with complex generics + # (e.g., Builder<*> vs Builder, parameter type narrowing in overrides) + - identifier: method.childReturnType + path: src/database/* + - identifier: method.childParameterType + path: src/database/* + + # Eloquent @mixin forwarding loses Eloquent\Builder type - methods forwarded via __call return Query\Builder + # but actually return $this (Eloquent\Builder) at runtime + - message: '#Method .* should return Hypervel\\Database\\Eloquent\\Builder<.*> but returns Hypervel\\Database\\Query\\Builder\.$#' + path: src/database/* + + # Relation @mixin forwarding - Relation methods returning $this forward to Builder methods + # PHPStan sees Builder return type but the actual return is $this (the Relation) + - message: '#should return \$this\(Hypervel\\Database\\Eloquent\\Relations\\.+\) but returns Hypervel\\Database\\(Query|Eloquent)\\Builder#' + path: src/database/* + + # Collection template covariance - specific array shapes are subtypes of array + # but Collection is invariant, so PHPStan rejects the more specific return type + - message: '#should return Hypervel\\Support\\Collection<.+, array<.+>> but returns Hypervel\\Support\\Collection<.+, array\{#' + + # BelongsToMany pivot intersection type - PHPDoc uses object{pivot: ...}&TRelatedModel + # to document that models get a pivot property attached, but PHPStan can't track dynamic attachment + - 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\.#' + + # Boolean narrowing issues - PHPStan over-narrows types in conditionals + - identifier: booleanAnd.rightAlwaysTrue + path: src/database/* + + # Eloquent Model magic __call forwarding to Query Builder + - '#Call to an undefined method Hypervel\\Database\\Eloquent\\Model::(where|whereIn|find|first|get|create|update|forceCreate)\(\)#' + - '#Call to an undefined method Hypervel\\Database\\Query\\Builder::(with|load)\(\)#' + + # Driver-specific methods (MySQL/MariaDB) + - '#Call to an undefined method Hypervel\\Database\\Connection::isMaria\(\)#' + + # Non-generic interface usage in PHPDoc (Arrayable, etc.) + - '#PHPDoc tag @return contains generic type Hypervel\\Support\\Contracts\\Arrayable<.*> but interface .* is not generic#' + - '#PHPDoc tag @param contains generic type Hypervel\\Support\\Contracts\\Arrayable<.*> but interface .* is not generic#' - '#Result of method .* \(void\) is used\.#' - '#Unsafe usage of new static#' - '#Class [a-zA-Z0-9\\\\_]+ not found.#' @@ -38,25 +120,14 @@ parameters: path: src/foundation/src/Testing/TestCase.php - '#Method Redis::eval\(\) invoked with [0-9] parameters, 1-3 required.#' - '#Access to an undefined property Hypervel\\Queue\\Jobs\\DatabaseJobRecord::\$.*#' - - '#Access to an undefined property Hypervel\\Queue\\Contracts\\Job::\$.*#' + - '#Access to an undefined property Hypervel\\Contracts\\Queue\\Job::\$.*#' - '#Call to an undefined method Hyperf\\Database\\Query\\Builder::where[a-zA-Z0-9\\\\_]+#' - '#Call to an undefined method Hyperf\\Database\\Query\\Builder::firstOrFail\(\)#' - - '#Access to an undefined property Hyperf\\Collection\\HigherOrderCollectionProxy#' - - '#Call to an undefined method Hyperf\\Tappable\\HigherOrderTapProxy#' - - message: '#.*#' - paths: - - src/support/src/Collection.php - - src/core/src/Database/Eloquent/Builder.php - - src/core/src/Database/Eloquent/Collection.php - - src/core/src/Database/Eloquent/Concerns/HasRelationships.php - - src/core/src/Database/Eloquent/Concerns/QueriesRelationships.php - - src/core/src/Database/Eloquent/Relations/BelongsToMany.php - - src/core/src/Database/Eloquent/Relations/HasMany.php - - src/core/src/Database/Eloquent/Relations/HasManyThrough.php - - src/core/src/Database/Eloquent/Relations/HasOne.php - - src/core/src/Database/Eloquent/Relations/HasOneThrough.php - - src/core/src/Database/Eloquent/Relations/MorphMany.php - - src/core/src/Database/Eloquent/Relations/MorphOne.php - - src/core/src/Database/Eloquent/Relations/MorphTo.php - - src/core/src/Database/Eloquent/Relations/MorphToMany.php - - src/core/src/Database/Eloquent/Relations/Relation.php + - '#Access to an undefined property (Hyperf\\Collection|Hypervel\\Support)\\HigherOrderCollectionProxy#' + - '#Call to an undefined method Hypervel\\Support\\HigherOrderTapProxy#' + + # Optional class uses magic __get to proxy property access to wrapped value + - '#Access to an undefined property Hypervel\\Support\\Optional::\$#' + + # Generic type loss through Builder chain - firstOrFail() returns TModel but phpstan loses it + - '#Cannot call method load\(\) on stdClass#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index cc75f1b1b..0df9281e9 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,6 @@ ./tests/Horizon + + + diff --git a/src/api-client/src/ApiResource.php b/src/api-client/src/ApiResource.php index f726897c0..585e9369d 100644 --- a/src/api-client/src/ApiResource.php +++ b/src/api-client/src/ApiResource.php @@ -6,9 +6,9 @@ use ArrayAccess; use BadMethodCallException; -use Hyperf\Contract\Arrayable; -use Hyperf\Contract\Jsonable; use Hyperf\Support\Traits\ForwardsCalls; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; use JsonSerializable; use Stringable; @@ -105,6 +105,14 @@ public function toArray(): array return $this->response->json(); } + /** + * Convert the resource to its JSON representation. + */ + public function toJson(int $options = 0): string + { + return json_encode($this->jsonSerialize(), $options); + } + /** * Prepare the resource for JSON serialization. */ diff --git a/src/auth/composer.json b/src/auth/composer.json index 0d5267aa1..65e9b91b4 100644 --- a/src/auth/composer.json +++ b/src/auth/composer.json @@ -23,12 +23,11 @@ "php": "^8.2", "nesbot/carbon": "^2.72.6", "hyperf/context": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/macroable": "~3.1.0", + "hypervel/macroable": "~0.3.0", "hyperf/contract": "~3.1.0", "hyperf/config": "~3.1.0", - "hyperf/database": "~3.1.0", "hyperf/http-server": "~3.1.0", + "hypervel/database": "^0.3", "hypervel/hashing": "^0.3", "hypervel/jwt": "^0.3" }, diff --git a/src/auth/src/Access/Authorizable.php b/src/auth/src/Access/Authorizable.php index 5977bd1bf..59d7811d1 100644 --- a/src/auth/src/Access/Authorizable.php +++ b/src/auth/src/Access/Authorizable.php @@ -5,7 +5,7 @@ namespace Hypervel\Auth\Access; use Hyperf\Context\ApplicationContext; -use Hypervel\Auth\Contracts\Gate; +use Hypervel\Contracts\Auth\Access\Gate; trait Authorizable { diff --git a/src/auth/src/Access/AuthorizesRequests.php b/src/auth/src/Access/AuthorizesRequests.php index d032c0039..e0fca527f 100644 --- a/src/auth/src/Access/AuthorizesRequests.php +++ b/src/auth/src/Access/AuthorizesRequests.php @@ -5,8 +5,8 @@ namespace Hypervel\Auth\Access; use Hyperf\Context\ApplicationContext; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Gate; +use Hypervel\Contracts\Auth\Access\Gate; +use Hypervel\Contracts\Auth\Authenticatable; use function Hypervel\Support\enum_value; diff --git a/src/auth/src/Access/Events/GateEvaluated.php b/src/auth/src/Access/Events/GateEvaluated.php index 35760f874..abea10cb4 100644 --- a/src/auth/src/Access/Events/GateEvaluated.php +++ b/src/auth/src/Access/Events/GateEvaluated.php @@ -5,7 +5,7 @@ namespace Hypervel\Auth\Access\Events; use Hypervel\Auth\Access\Response; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; class GateEvaluated { diff --git a/src/auth/src/Access/Gate.php b/src/auth/src/Access/Gate.php index e8741f0c9..f7228fa98 100644 --- a/src/auth/src/Access/Gate.php +++ b/src/auth/src/Access/Gate.php @@ -6,14 +6,14 @@ use Closure; use Exception; -use Hyperf\Collection\Arr; use Hyperf\Contract\ContainerInterface; use Hyperf\Di\Exception\NotFoundException; -use Hyperf\Stringable\Str; use Hypervel\Auth\Access\Events\GateEvaluated; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Gate as GateContract; +use Hypervel\Contracts\Auth\Access\Gate as GateContract; +use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Database\Eloquent\Attributes\UsePolicy; +use Hypervel\Support\Arr; +use Hypervel\Support\Str; use InvalidArgumentException; use Psr\EventDispatcher\EventDispatcherInterface; use ReflectionClass; diff --git a/src/auth/src/Access/GateFactory.php b/src/auth/src/Access/GateFactory.php index fed5bfe65..9063205ca 100644 --- a/src/auth/src/Access/GateFactory.php +++ b/src/auth/src/Access/GateFactory.php @@ -5,7 +5,7 @@ namespace Hypervel\Auth\Access; use Hyperf\Contract\ContainerInterface; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; use function Hyperf\Support\make; diff --git a/src/auth/src/Access/Response.php b/src/auth/src/Access/Response.php index adf6aed5f..c15122927 100644 --- a/src/auth/src/Access/Response.php +++ b/src/auth/src/Access/Response.php @@ -4,7 +4,7 @@ namespace Hypervel\Auth\Access; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Support\Arrayable; use Stringable; class Response implements Arrayable, Stringable diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index 5c0199ffe..9997e286e 100644 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -8,14 +8,14 @@ use Hyperf\Context\Context; use Hyperf\Contract\ConfigInterface; use Hyperf\HttpServer\Contract\RequestInterface; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; -use Hypervel\Auth\Contracts\Guard; -use Hypervel\Auth\Contracts\StatefulGuard; use Hypervel\Auth\Guards\JwtGuard; use Hypervel\Auth\Guards\RequestGuard; use Hypervel\Auth\Guards\SessionGuard; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; +use Hypervel\Contracts\Auth\Guard; +use Hypervel\Contracts\Auth\StatefulGuard; +use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\JWT\JWTManager; -use Hypervel\Session\Contracts\Session as SessionContract; use InvalidArgumentException; use Psr\Container\ContainerInterface; diff --git a/src/auth/src/ConfigProvider.php b/src/auth/src/ConfigProvider.php index 94ddbbc76..caac869a8 100644 --- a/src/auth/src/ConfigProvider.php +++ b/src/auth/src/ConfigProvider.php @@ -5,10 +5,10 @@ namespace Hypervel\Auth; use Hypervel\Auth\Access\GateFactory; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; -use Hypervel\Auth\Contracts\Gate as GateContract; -use Hypervel\Auth\Contracts\Guard; +use Hypervel\Contracts\Auth\Access\Gate as GateContract; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; +use Hypervel\Contracts\Auth\Guard; class ConfigProvider { diff --git a/src/auth/src/CreatesUserProviders.php b/src/auth/src/CreatesUserProviders.php index e1eb5266c..4b76536a0 100644 --- a/src/auth/src/CreatesUserProviders.php +++ b/src/auth/src/CreatesUserProviders.php @@ -4,11 +4,11 @@ namespace Hypervel\Auth; -use Hyperf\Database\ConnectionResolverInterface; -use Hypervel\Auth\Contracts\UserProvider; use Hypervel\Auth\Providers\DatabaseUserProvider; use Hypervel\Auth\Providers\EloquentUserProvider; -use Hypervel\Hashing\Contracts\Hasher as HashContract; +use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Hashing\Hasher as HashContract; +use Hypervel\Database\ConnectionResolverInterface; use InvalidArgumentException; trait CreatesUserProviders diff --git a/src/auth/src/Functions.php b/src/auth/src/Functions.php index 8f75101d6..ab8b9e010 100644 --- a/src/auth/src/Functions.php +++ b/src/auth/src/Functions.php @@ -5,8 +5,8 @@ namespace Hypervel\Auth; use Hyperf\Context\ApplicationContext; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; -use Hypervel\Auth\Contracts\Guard; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; +use Hypervel\Contracts\Auth\Guard; /** * Get auth guard or auth manager. diff --git a/src/auth/src/GenericUser.php b/src/auth/src/GenericUser.php index e02f2b0b7..8b11e5086 100644 --- a/src/auth/src/GenericUser.php +++ b/src/auth/src/GenericUser.php @@ -4,7 +4,7 @@ namespace Hypervel\Auth; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; class GenericUser implements Authenticatable { diff --git a/src/auth/src/Guards/GuardHelpers.php b/src/auth/src/Guards/GuardHelpers.php index 46506ce0b..3d26a44ea 100644 --- a/src/auth/src/Guards/GuardHelpers.php +++ b/src/auth/src/Guards/GuardHelpers.php @@ -5,8 +5,8 @@ namespace Hypervel\Auth\Guards; use Hypervel\Auth\AuthenticationException; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\UserProvider; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\UserProvider; /** * These methods are typically the same across all guards. diff --git a/src/auth/src/Guards/JwtGuard.php b/src/auth/src/Guards/JwtGuard.php index 6b067ca23..699db86bb 100644 --- a/src/auth/src/Guards/JwtGuard.php +++ b/src/auth/src/Guards/JwtGuard.php @@ -8,12 +8,12 @@ use Hyperf\Context\Context; use Hyperf\Context\RequestContext; use Hyperf\HttpServer\Contract\RequestInterface; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Str; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Guard; -use Hypervel\Auth\Contracts\UserProvider; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\Guard; +use Hypervel\Contracts\Auth\UserProvider; use Hypervel\JWT\Contracts\ManagerContract; +use Hypervel\Support\Str; +use Hypervel\Support\Traits\Macroable; use Throwable; class JwtGuard implements Guard diff --git a/src/auth/src/Guards/RequestGuard.php b/src/auth/src/Guards/RequestGuard.php index 8bee15fc0..03295b2a6 100644 --- a/src/auth/src/Guards/RequestGuard.php +++ b/src/auth/src/Guards/RequestGuard.php @@ -7,10 +7,10 @@ use Hyperf\Context\ApplicationContext; use Hyperf\Context\Context; use Hyperf\HttpServer\Contract\RequestInterface; -use Hyperf\Macroable\Macroable; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Guard; -use Hypervel\Auth\Contracts\UserProvider; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\Guard; +use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Support\Traits\Macroable; use Throwable; class RequestGuard implements Guard diff --git a/src/auth/src/Guards/SessionGuard.php b/src/auth/src/Guards/SessionGuard.php index 7a2794a2a..28d5c77cc 100644 --- a/src/auth/src/Guards/SessionGuard.php +++ b/src/auth/src/Guards/SessionGuard.php @@ -5,11 +5,11 @@ namespace Hypervel\Auth\Guards; use Hyperf\Context\Context; -use Hyperf\Macroable\Macroable; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\StatefulGuard; -use Hypervel\Auth\Contracts\UserProvider; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\StatefulGuard; +use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Session\Session as SessionContract; +use Hypervel\Support\Traits\Macroable; use Throwable; class SessionGuard implements StatefulGuard diff --git a/src/auth/src/Middleware/Authorize.php b/src/auth/src/Middleware/Authorize.php index fa07c1aa5..c899adbcd 100644 --- a/src/auth/src/Middleware/Authorize.php +++ b/src/auth/src/Middleware/Authorize.php @@ -4,11 +4,11 @@ namespace Hypervel\Auth\Middleware; -use Hyperf\Collection\Collection; -use Hyperf\Database\Model\Model; use Hyperf\HttpServer\Router\Dispatched; use Hypervel\Auth\Access\AuthorizationException; -use Hypervel\Auth\Contracts\Gate; +use Hypervel\Contracts\Auth\Access\Gate; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\Collection; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; diff --git a/src/auth/src/Providers/DatabaseUserProvider.php b/src/auth/src/Providers/DatabaseUserProvider.php index 703fc2ddc..e5139bf8b 100644 --- a/src/auth/src/Providers/DatabaseUserProvider.php +++ b/src/auth/src/Providers/DatabaseUserProvider.php @@ -5,12 +5,12 @@ namespace Hypervel\Auth\Providers; use Closure; -use Hyperf\Contract\Arrayable; -use Hyperf\Database\ConnectionInterface; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\UserProvider; use Hypervel\Auth\GenericUser; -use Hypervel\Hashing\Contracts\Hasher as HashContract; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Hashing\Hasher as HashContract; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Database\ConnectionInterface; class DatabaseUserProvider implements UserProvider { diff --git a/src/auth/src/Providers/EloquentUserProvider.php b/src/auth/src/Providers/EloquentUserProvider.php index f27ff61fd..9d7b6bbf8 100644 --- a/src/auth/src/Providers/EloquentUserProvider.php +++ b/src/auth/src/Providers/EloquentUserProvider.php @@ -5,12 +5,12 @@ namespace Hypervel\Auth\Providers; use Closure; -use Hyperf\Contract\Arrayable; -use Hyperf\Database\Model\Builder; -use Hyperf\Database\Model\Model; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\UserProvider; -use Hypervel\Hashing\Contracts\Hasher as HashContract; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Hashing\Hasher as HashContract; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Model; use function Hyperf\Support\with; @@ -19,7 +19,7 @@ class EloquentUserProvider implements UserProvider /** * The callback that may modify the user retrieval queries. * - * @var null|(Closure(\Hyperf\Database\Model\Builder):mixed) + * @var null|(Closure(\Hypervel\Database\Eloquent\Builder):mixed) */ protected $queryCallback; @@ -176,7 +176,7 @@ public function getQueryCallback(): ?Closure /** * Sets the callback to modify the query before retrieving users. * - * @param null|(Closure(\Hyperf\Database\Model\Builder):mixed) $queryCallback + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder):mixed) $queryCallback * * @return $this */ diff --git a/src/auth/src/UserResolver.php b/src/auth/src/UserResolver.php index a27866f23..ef662afa7 100644 --- a/src/auth/src/UserResolver.php +++ b/src/auth/src/UserResolver.php @@ -4,7 +4,7 @@ namespace Hypervel\Auth; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; use Psr\Container\ContainerInterface; class UserResolver diff --git a/src/broadcasting/src/AnonymousEvent.php b/src/broadcasting/src/AnonymousEvent.php index 2943383a6..07e44cfde 100644 --- a/src/broadcasting/src/AnonymousEvent.php +++ b/src/broadcasting/src/AnonymousEvent.php @@ -4,12 +4,10 @@ namespace Hypervel\Broadcasting; -use Hyperf\Collection\Arr; -use Hyperf\Contract\Arrayable; -use Hypervel\Broadcasting\Contracts\ShouldBroadcast; +use Hypervel\Contracts\Broadcasting\ShouldBroadcast; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Foundation\Events\Dispatchable; - -use function Hyperf\Collection\collect; +use Hypervel\Support\Arr; class AnonymousEvent implements ShouldBroadcast { diff --git a/src/broadcasting/src/BroadcastEvent.php b/src/broadcasting/src/BroadcastEvent.php index c4b13c679..1b24af18d 100644 --- a/src/broadcasting/src/BroadcastEvent.php +++ b/src/broadcasting/src/BroadcastEvent.php @@ -4,11 +4,11 @@ namespace Hypervel\Broadcasting; -use Hyperf\Collection\Arr; -use Hyperf\Contract\Arrayable; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastingFactory; use Hypervel\Bus\Queueable; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; +use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Support\Arr; use ReflectionClass; use ReflectionProperty; diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 10730e715..7246e18d1 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -16,17 +16,17 @@ use Hypervel\Broadcasting\Broadcasters\NullBroadcaster; use Hypervel\Broadcasting\Broadcasters\PusherBroadcaster; use Hypervel\Broadcasting\Broadcasters\RedisBroadcaster; -use Hypervel\Broadcasting\Contracts\Broadcaster; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastingFactoryContract; -use Hypervel\Broadcasting\Contracts\ShouldBeUnique; -use Hypervel\Broadcasting\Contracts\ShouldBroadcastNow; -use Hypervel\Bus\Contracts\Dispatcher; use Hypervel\Bus\UniqueLock; -use Hypervel\Cache\Contracts\Factory as Cache; +use Hypervel\Contracts\Broadcasting\Broadcaster; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactoryContract; +use Hypervel\Contracts\Broadcasting\ShouldBeUnique; +use Hypervel\Contracts\Broadcasting\ShouldBroadcastNow; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Cache\Factory as Cache; +use Hypervel\Contracts\Queue\Factory as Queue; use Hypervel\Foundation\Http\Kernel; use Hypervel\Foundation\Http\Middleware\VerifyCsrfToken; use Hypervel\ObjectPool\Traits\HasPoolProxy; -use Hypervel\Queue\Contracts\Factory as Queue; use InvalidArgumentException; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/src/broadcasting/src/BroadcastPoolProxy.php b/src/broadcasting/src/BroadcastPoolProxy.php index a15cb0c22..baa83d0ff 100644 --- a/src/broadcasting/src/BroadcastPoolProxy.php +++ b/src/broadcasting/src/BroadcastPoolProxy.php @@ -5,8 +5,8 @@ namespace Hypervel\Broadcasting; use Hyperf\HttpServer\Contract\RequestInterface; -use Hypervel\Broadcasting\Contracts\Broadcaster; -use Hypervel\Broadcasting\Contracts\HasBroadcastChannel; +use Hypervel\Contracts\Broadcasting\Broadcaster; +use Hypervel\Contracts\Broadcasting\HasBroadcastChannel; use Hypervel\ObjectPool\PoolProxy; class BroadcastPoolProxy extends PoolProxy implements Broadcaster diff --git a/src/broadcasting/src/Broadcasters/AblyBroadcaster.php b/src/broadcasting/src/Broadcasters/AblyBroadcaster.php index 24516ae52..d71c4ff65 100644 --- a/src/broadcasting/src/Broadcasters/AblyBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/AblyBroadcaster.php @@ -8,13 +8,11 @@ use Ably\Exceptions\AblyException; use Ably\Models\Message as AblyMessage; use Hyperf\HttpServer\Contract\RequestInterface; -use Hyperf\Stringable\Str; use Hypervel\Broadcasting\BroadcastException; use Hypervel\HttpMessage\Exceptions\AccessDeniedHttpException; +use Hypervel\Support\Str; use Psr\Container\ContainerInterface; -use function Hyperf\Tappable\tap; - class AblyBroadcaster extends Broadcaster { /** diff --git a/src/broadcasting/src/Broadcasters/Broadcaster.php b/src/broadcasting/src/Broadcasters/Broadcaster.php index da5c40d96..d29fb4457 100644 --- a/src/broadcasting/src/Broadcasters/Broadcaster.php +++ b/src/broadcasting/src/Broadcasters/Broadcaster.php @@ -6,13 +6,13 @@ use Closure; use Exception; -use Hyperf\Collection\Arr; use Hyperf\HttpServer\Contract\RequestInterface; use Hypervel\Auth\AuthManager; -use Hypervel\Broadcasting\Contracts\Broadcaster as BroadcasterContract; -use Hypervel\Broadcasting\Contracts\HasBroadcastChannel; +use Hypervel\Contracts\Broadcasting\Broadcaster as BroadcasterContract; +use Hypervel\Contracts\Broadcasting\HasBroadcastChannel; +use Hypervel\Contracts\Router\UrlRoutable; use Hypervel\HttpMessage\Exceptions\AccessDeniedHttpException; -use Hypervel\Router\Contracts\UrlRoutable; +use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Reflector; use Psr\Container\ContainerInterface; diff --git a/src/broadcasting/src/Broadcasters/PusherBroadcaster.php b/src/broadcasting/src/Broadcasters/PusherBroadcaster.php index 66adeb614..665b3106e 100644 --- a/src/broadcasting/src/Broadcasters/PusherBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/PusherBroadcaster.php @@ -4,11 +4,11 @@ namespace Hypervel\Broadcasting\Broadcasters; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\HttpServer\Contract\RequestInterface; use Hypervel\Broadcasting\BroadcastException; use Hypervel\HttpMessage\Exceptions\AccessDeniedHttpException; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Psr\Container\ContainerInterface; use Pusher\ApiErrorException; use Pusher\Pusher; diff --git a/src/broadcasting/src/Broadcasters/RedisBroadcaster.php b/src/broadcasting/src/Broadcasters/RedisBroadcaster.php index ffc776897..7e893ef4a 100644 --- a/src/broadcasting/src/Broadcasters/RedisBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/RedisBroadcaster.php @@ -4,12 +4,12 @@ namespace Hypervel\Broadcasting\Broadcasters; -use Hyperf\Collection\Arr; use Hyperf\HttpServer\Contract\RequestInterface; -use Hyperf\Pool\Exception\ConnectionException; use Hyperf\Redis\RedisFactory; use Hypervel\Broadcasting\BroadcastException; use Hypervel\HttpMessage\Exceptions\AccessDeniedHttpException; +use Hypervel\Pool\Exception\ConnectionException; +use Hypervel\Support\Arr; use Psr\Container\ContainerInterface; use RedisException; diff --git a/src/broadcasting/src/Broadcasters/UsePusherChannelConventions.php b/src/broadcasting/src/Broadcasters/UsePusherChannelConventions.php index e003f70f4..2ad6d5994 100644 --- a/src/broadcasting/src/Broadcasters/UsePusherChannelConventions.php +++ b/src/broadcasting/src/Broadcasters/UsePusherChannelConventions.php @@ -4,7 +4,7 @@ namespace Hypervel\Broadcasting\Broadcasters; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; trait UsePusherChannelConventions { diff --git a/src/broadcasting/src/Channel.php b/src/broadcasting/src/Channel.php index 29a437003..85e11586a 100644 --- a/src/broadcasting/src/Channel.php +++ b/src/broadcasting/src/Channel.php @@ -4,7 +4,7 @@ namespace Hypervel\Broadcasting; -use Hypervel\Broadcasting\Contracts\HasBroadcastChannel; +use Hypervel\Contracts\Broadcasting\HasBroadcastChannel; use Stringable; class Channel implements Stringable diff --git a/src/broadcasting/src/ConfigProvider.php b/src/broadcasting/src/ConfigProvider.php index 16df0c7a9..653f3be43 100644 --- a/src/broadcasting/src/ConfigProvider.php +++ b/src/broadcasting/src/ConfigProvider.php @@ -4,7 +4,7 @@ namespace Hypervel\Broadcasting; -use Hypervel\Broadcasting\Contracts\Factory; +use Hypervel\Contracts\Broadcasting\Factory; class ConfigProvider { diff --git a/src/broadcasting/src/InteractsWithBroadcasting.php b/src/broadcasting/src/InteractsWithBroadcasting.php index 783149f8b..f282901bd 100644 --- a/src/broadcasting/src/InteractsWithBroadcasting.php +++ b/src/broadcasting/src/InteractsWithBroadcasting.php @@ -4,7 +4,7 @@ namespace Hypervel\Broadcasting; -use Hyperf\Collection\Arr; +use Hypervel\Support\Arr; use UnitEnum; use function Hypervel\Support\enum_value; diff --git a/src/broadcasting/src/PrivateChannel.php b/src/broadcasting/src/PrivateChannel.php index cfb19f01c..4813e82b6 100644 --- a/src/broadcasting/src/PrivateChannel.php +++ b/src/broadcasting/src/PrivateChannel.php @@ -4,7 +4,7 @@ namespace Hypervel\Broadcasting; -use Hypervel\Broadcasting\Contracts\HasBroadcastChannel; +use Hypervel\Contracts\Broadcasting\HasBroadcastChannel; class PrivateChannel extends Channel { diff --git a/src/broadcasting/src/UniqueBroadcastEvent.php b/src/broadcasting/src/UniqueBroadcastEvent.php index 03b84b435..4479da825 100644 --- a/src/broadcasting/src/UniqueBroadcastEvent.php +++ b/src/broadcasting/src/UniqueBroadcastEvent.php @@ -5,8 +5,8 @@ namespace Hypervel\Broadcasting; use Hyperf\Context\ApplicationContext; -use Hypervel\Cache\Contracts\Factory as Cache; -use Hypervel\Queue\Contracts\ShouldBeUnique; +use Hypervel\Contracts\Cache\Factory as Cache; +use Hypervel\Contracts\Queue\ShouldBeUnique; class UniqueBroadcastEvent extends BroadcastEvent implements ShouldBeUnique { diff --git a/src/bus/composer.json b/src/bus/composer.json index 458e467c6..2a51a072e 100644 --- a/src/bus/composer.json +++ b/src/bus/composer.json @@ -24,7 +24,7 @@ "php": "^8.2", "hyperf/context": "~3.1.0", "hyperf/contract": "~3.1.0", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", "hyperf/conditionable": "~3.1.0", "hyperf/coroutine": "~3.1.0", "hyperf/support": "~3.1.0", diff --git a/src/bus/src/Batch.php b/src/bus/src/Batch.php index 83858bed5..b6accbe4d 100644 --- a/src/bus/src/Batch.php +++ b/src/bus/src/Batch.php @@ -6,15 +6,15 @@ use Carbon\CarbonInterface; use Closure; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; -use Hyperf\Collection\Enumerable; use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\Arrayable; -use Hypervel\Bus\Contracts\BatchRepository; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Queue\Factory as QueueFactory; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Queue\CallQueuedClosure; -use Hypervel\Queue\Contracts\Factory as QueueFactory; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; +use Hypervel\Support\Enumerable; use JsonSerializable; use Throwable; diff --git a/src/bus/src/BatchFactory.php b/src/bus/src/BatchFactory.php index 74bfab1f9..67181fac6 100644 --- a/src/bus/src/BatchFactory.php +++ b/src/bus/src/BatchFactory.php @@ -5,8 +5,8 @@ namespace Hypervel\Bus; use Carbon\CarbonImmutable; -use Hypervel\Bus\Contracts\BatchRepository; -use Hypervel\Queue\Contracts\Factory as QueueFactory; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Queue\Factory as QueueFactory; class BatchFactory { diff --git a/src/bus/src/Batchable.php b/src/bus/src/Batchable.php index 86dba14c1..961837881 100644 --- a/src/bus/src/Batchable.php +++ b/src/bus/src/Batchable.php @@ -6,8 +6,8 @@ use Carbon\CarbonImmutable; use Hyperf\Context\ApplicationContext; -use Hyperf\Stringable\Str; -use Hypervel\Bus\Contracts\BatchRepository; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Support\Str; use Hypervel\Support\Testing\Fakes\BatchFake; trait Batchable diff --git a/src/bus/src/ChainedBatch.php b/src/bus/src/ChainedBatch.php index 7e9b00536..242210743 100644 --- a/src/bus/src/ChainedBatch.php +++ b/src/bus/src/ChainedBatch.php @@ -4,11 +4,11 @@ namespace Hypervel\Bus; -use Hyperf\Collection\Collection; use Hyperf\Context\ApplicationContext; -use Hypervel\Bus\Contracts\Dispatcher; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\InteractsWithQueue; +use Hypervel\Support\Collection; use Throwable; class ChainedBatch implements ShouldQueue diff --git a/src/bus/src/ConfigProvider.php b/src/bus/src/ConfigProvider.php index 912491f9d..e50732d86 100644 --- a/src/bus/src/ConfigProvider.php +++ b/src/bus/src/ConfigProvider.php @@ -4,8 +4,8 @@ namespace Hypervel\Bus; -use Hypervel\Bus\Contracts\BatchRepository; -use Hypervel\Bus\Contracts\Dispatcher as DispatcherContract; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Bus\Dispatcher as DispatcherContract; use Psr\Container\ContainerInterface; class ConfigProvider diff --git a/src/bus/src/DatabaseBatchRepository.php b/src/bus/src/DatabaseBatchRepository.php index af8265d91..8b8edc16a 100644 --- a/src/bus/src/DatabaseBatchRepository.php +++ b/src/bus/src/DatabaseBatchRepository.php @@ -7,11 +7,11 @@ use Carbon\CarbonImmutable; use Closure; use DateTimeInterface; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Expression; -use Hyperf\Stringable\Str; -use Hypervel\Bus\Contracts\PrunableBatchRepository; +use Hypervel\Contracts\Bus\PrunableBatchRepository; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Expression; +use Hypervel\Support\Str; use Throwable; class DatabaseBatchRepository implements PrunableBatchRepository diff --git a/src/bus/src/DatabaseBatchRepositoryFactory.php b/src/bus/src/DatabaseBatchRepositoryFactory.php index 9b5def8d7..419497735 100644 --- a/src/bus/src/DatabaseBatchRepositoryFactory.php +++ b/src/bus/src/DatabaseBatchRepositoryFactory.php @@ -5,7 +5,7 @@ namespace Hypervel\Bus; use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\ConnectionResolverInterface; +use Hypervel\Database\ConnectionResolverInterface; use Psr\Container\ContainerInterface; class DatabaseBatchRepositoryFactory diff --git a/src/bus/src/Dispatchable.php b/src/bus/src/Dispatchable.php index 1394b0cf3..05978b5ac 100644 --- a/src/bus/src/Dispatchable.php +++ b/src/bus/src/Dispatchable.php @@ -7,7 +7,7 @@ use Closure; use Hyperf\Context\ApplicationContext; use Hyperf\Support\Fluent; -use Hypervel\Bus\Contracts\Dispatcher; +use Hypervel\Contracts\Bus\Dispatcher; use function Hyperf\Support\value; diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index ad67d754c..e3c4ff02f 100644 --- a/src/bus/src/Dispatcher.php +++ b/src/bus/src/Dispatcher.php @@ -5,14 +5,14 @@ namespace Hypervel\Bus; use Closure; -use Hyperf\Collection\Collection; use Hyperf\Coroutine\Coroutine; -use Hypervel\Bus\Contracts\BatchRepository; -use Hypervel\Bus\Contracts\QueueingDispatcher; -use Hypervel\Queue\Contracts\Queue; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Bus\QueueingDispatcher; +use Hypervel\Contracts\Queue\Queue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Jobs\SyncJob; +use Hypervel\Support\Collection; use Hypervel\Support\Pipeline; use Psr\Container\ContainerInterface; use RuntimeException; diff --git a/src/bus/src/DispatcherFactory.php b/src/bus/src/DispatcherFactory.php index 1459982a3..efde8c5ae 100644 --- a/src/bus/src/DispatcherFactory.php +++ b/src/bus/src/DispatcherFactory.php @@ -4,7 +4,7 @@ namespace Hypervel\Bus; -use Hypervel\Queue\Contracts\Factory as QueueFactoryContract; +use Hypervel\Contracts\Queue\Factory as QueueFactoryContract; use Psr\Container\ContainerInterface; class DispatcherFactory diff --git a/src/bus/src/Functions.php b/src/bus/src/Functions.php index 2d5ff7d93..64ac01422 100644 --- a/src/bus/src/Functions.php +++ b/src/bus/src/Functions.php @@ -6,7 +6,7 @@ use Closure; use Hyperf\Context\ApplicationContext; -use Hypervel\Bus\Contracts\Dispatcher; +use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Queue\CallQueuedClosure; /** diff --git a/src/bus/src/PendingBatch.php b/src/bus/src/PendingBatch.php index 085684c74..b86ec4523 100644 --- a/src/bus/src/PendingBatch.php +++ b/src/bus/src/PendingBatch.php @@ -5,13 +5,13 @@ namespace Hypervel\Bus; use Closure; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\Conditionable\Conditionable; use Hyperf\Coroutine\Coroutine; -use Hypervel\Bus\Contracts\BatchRepository; use Hypervel\Bus\Events\BatchDispatched; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Laravel\SerializableClosure\SerializableClosure; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/src/bus/src/PendingChain.php b/src/bus/src/PendingChain.php index bcb884033..fdd526114 100644 --- a/src/bus/src/PendingChain.php +++ b/src/bus/src/PendingChain.php @@ -9,7 +9,7 @@ use DateTimeInterface; use Hyperf\Conditionable\Conditionable; use Hyperf\Context\ApplicationContext; -use Hypervel\Bus\Contracts\Dispatcher; +use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Queue\CallQueuedClosure; use Laravel\SerializableClosure\SerializableClosure; use UnitEnum; diff --git a/src/bus/src/PendingDispatch.php b/src/bus/src/PendingDispatch.php index 6649cd1b7..799361ca0 100644 --- a/src/bus/src/PendingDispatch.php +++ b/src/bus/src/PendingDispatch.php @@ -7,9 +7,9 @@ use DateInterval; use DateTimeInterface; use Hyperf\Context\ApplicationContext; -use Hypervel\Bus\Contracts\Dispatcher; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Queue\Contracts\ShouldBeUnique; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Queue\ShouldBeUnique; use UnitEnum; class PendingDispatch diff --git a/src/bus/src/Queueable.php b/src/bus/src/Queueable.php index a27e9fed5..7b58fd3a7 100644 --- a/src/bus/src/Queueable.php +++ b/src/bus/src/Queueable.php @@ -7,9 +7,9 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hypervel\Queue\CallQueuedClosure; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use PHPUnit\Framework\Assert as PHPUnit; use RuntimeException; use Throwable; diff --git a/src/bus/src/UniqueLock.php b/src/bus/src/UniqueLock.php index d23520110..e8bd7431e 100644 --- a/src/bus/src/UniqueLock.php +++ b/src/bus/src/UniqueLock.php @@ -4,7 +4,7 @@ namespace Hypervel\Bus; -use Hypervel\Cache\Contracts\Factory as CacheFactory; +use Hypervel\Contracts\Cache\Factory as CacheFactory; class UniqueLock { diff --git a/src/cache/publish/cache.php b/src/cache/publish/cache.php index 226035f8d..2e02a364f 100644 --- a/src/cache/publish/cache.php +++ b/src/cache/publish/cache.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Stringable\Str; use Hypervel\Cache\SwooleStore; +use Hypervel\Support\Str; use function Hyperf\Support\env; diff --git a/src/cache/src/ArrayLock.php b/src/cache/src/ArrayLock.php index e6958aa26..35de2e579 100644 --- a/src/cache/src/ArrayLock.php +++ b/src/cache/src/ArrayLock.php @@ -5,7 +5,7 @@ namespace Hypervel\Cache; use Carbon\Carbon; -use Hypervel\Cache\Contracts\RefreshableLock; +use Hypervel\Contracts\Cache\RefreshableLock; use InvalidArgumentException; class ArrayLock extends Lock implements RefreshableLock diff --git a/src/cache/src/ArrayStore.php b/src/cache/src/ArrayStore.php index b8b6d3e57..01fddfb9b 100644 --- a/src/cache/src/ArrayStore.php +++ b/src/cache/src/ArrayStore.php @@ -5,7 +5,7 @@ namespace Hypervel\Cache; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Cache\Contracts\LockProvider; +use Hypervel\Contracts\Cache\LockProvider; class ArrayStore extends TaggableStore implements LockProvider { diff --git a/src/cache/src/CacheLock.php b/src/cache/src/CacheLock.php index 2a517761c..06bcfc71c 100644 --- a/src/cache/src/CacheLock.php +++ b/src/cache/src/CacheLock.php @@ -4,7 +4,7 @@ namespace Hypervel\Cache; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\Store; class CacheLock extends Lock { diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index 63a5b0a68..329a89d4d 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -7,19 +7,18 @@ use Closure; use Hyperf\Contract\ConfigInterface; use Hyperf\Redis\RedisFactory; -use Hypervel\Cache\Contracts\Factory as FactoryContract; -use Hypervel\Cache\Contracts\Repository as RepositoryContract; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\Factory as FactoryContract; +use Hypervel\Contracts\Cache\Repository as RepositoryContract; +use Hypervel\Contracts\Cache\Store; use InvalidArgumentException; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface as DispatcherContract; use function Hyperf\Support\make; -use function Hyperf\Tappable\tap; /** - * @mixin \Hypervel\Cache\Contracts\Repository - * @mixin \Hypervel\Cache\Contracts\LockProvider + * @mixin \Hypervel\Contracts\Cache\Repository + * @mixin \Hypervel\Contracts\Cache\LockProvider * @mixin \Hypervel\Cache\TaggableStore */ class CacheManager implements FactoryContract @@ -284,7 +283,7 @@ protected function createStackDriver(array $config): Repository */ protected function createDatabaseDriver(array $config): Repository { - $connectionResolver = $this->app->get(\Hyperf\Database\ConnectionResolverInterface::class); + $connectionResolver = $this->app->get(\Hypervel\Database\ConnectionResolverInterface::class); $store = new DatabaseStore( $connectionResolver, diff --git a/src/cache/src/ConfigProvider.php b/src/cache/src/ConfigProvider.php index 36669872e..70ba724fd 100644 --- a/src/cache/src/ConfigProvider.php +++ b/src/cache/src/ConfigProvider.php @@ -6,10 +6,10 @@ use Hypervel\Cache\Console\ClearCommand; use Hypervel\Cache\Console\PruneDbExpiredCommand; -use Hypervel\Cache\Contracts\Factory; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Listeners\CreateSwooleTable; use Hypervel\Cache\Listeners\CreateTimer; +use Hypervel\Contracts\Cache\Factory; +use Hypervel\Contracts\Cache\Store; class ConfigProvider { diff --git a/src/cache/src/Console/ClearCommand.php b/src/cache/src/Console/ClearCommand.php index bc5ea3f9a..203e8c476 100644 --- a/src/cache/src/Console/ClearCommand.php +++ b/src/cache/src/Console/ClearCommand.php @@ -6,8 +6,8 @@ use Hyperf\Command\Command; use Hyperf\Support\Filesystem\Filesystem; -use Hypervel\Cache\Contracts\Factory as CacheContract; -use Hypervel\Cache\Contracts\Repository; +use Hypervel\Contracts\Cache\Factory as CacheContract; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Support\Traits\HasLaravelStyleCommand; use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Console\Input\InputArgument; diff --git a/src/cache/src/DatabaseLock.php b/src/cache/src/DatabaseLock.php index aaf9e0071..0c876534b 100644 --- a/src/cache/src/DatabaseLock.php +++ b/src/cache/src/DatabaseLock.php @@ -4,10 +4,10 @@ namespace Hypervel\Cache; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Exception\QueryException; -use Hypervel\Cache\Contracts\RefreshableLock; +use Hypervel\Contracts\Cache\RefreshableLock; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\QueryException; use InvalidArgumentException; use function Hyperf\Support\optional; diff --git a/src/cache/src/DatabaseStore.php b/src/cache/src/DatabaseStore.php index 8752ad4dd..d7107170b 100644 --- a/src/cache/src/DatabaseStore.php +++ b/src/cache/src/DatabaseStore.php @@ -5,13 +5,13 @@ namespace Hypervel\Cache; use Closure; -use Hyperf\Collection\Arr; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Builder; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Cache\Contracts\LockProvider; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\LockProvider; +use Hypervel\Contracts\Cache\Store; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; +use Hypervel\Support\Arr; use Throwable; class DatabaseStore implements Store, LockProvider diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 4d2cf1192..6b8dbe7a7 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -7,9 +7,9 @@ use Exception; use Hyperf\Support\Filesystem\Filesystem; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Cache\Contracts\LockProvider; -use Hypervel\Cache\Contracts\Store; -use Hypervel\Cache\Exceptions\LockTimeoutException; +use Hypervel\Contracts\Cache\LockProvider; +use Hypervel\Contracts\Cache\LockTimeoutException; +use Hypervel\Contracts\Cache\Store; use Hypervel\Filesystem\LockableFile; class FileStore implements Store, LockProvider diff --git a/src/cache/src/Functions.php b/src/cache/src/Functions.php index d18af2f6c..71b43ddaa 100644 --- a/src/cache/src/Functions.php +++ b/src/cache/src/Functions.php @@ -5,7 +5,7 @@ namespace Hypervel\Cache; use Hyperf\Context\ApplicationContext; -use Hypervel\Cache\Exceptions\InvalidArgumentException; +use Hypervel\Contracts\Cache\InvalidArgumentException; /** * Get / set the specified cache value. diff --git a/src/cache/src/Listeners/BaseListener.php b/src/cache/src/Listeners/BaseListener.php index e5a5d7671..ff7aa7721 100644 --- a/src/cache/src/Listeners/BaseListener.php +++ b/src/cache/src/Listeners/BaseListener.php @@ -4,9 +4,9 @@ namespace Hypervel\Cache\Listeners; -use Hyperf\Collection\Collection; use Hyperf\Contract\ConfigInterface; use Hyperf\Event\Contract\ListenerInterface; +use Hypervel\Support\Collection; use Psr\Container\ContainerInterface; abstract class BaseListener implements ListenerInterface diff --git a/src/cache/src/Lock.php b/src/cache/src/Lock.php index 55b5da9e0..74d3a58ee 100644 --- a/src/cache/src/Lock.php +++ b/src/cache/src/Lock.php @@ -4,10 +4,10 @@ namespace Hypervel\Cache; -use Hyperf\Stringable\Str; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Cache\Contracts\Lock as LockContract; -use Hypervel\Cache\Exceptions\LockTimeoutException; +use Hypervel\Contracts\Cache\Lock as LockContract; +use Hypervel\Contracts\Cache\LockTimeoutException; +use Hypervel\Support\Str; abstract class Lock implements LockContract { diff --git a/src/cache/src/NoLock.php b/src/cache/src/NoLock.php index 769d25cfd..18314e3ec 100644 --- a/src/cache/src/NoLock.php +++ b/src/cache/src/NoLock.php @@ -4,7 +4,7 @@ namespace Hypervel\Cache; -use Hypervel\Cache\Contracts\RefreshableLock; +use Hypervel\Contracts\Cache\RefreshableLock; use InvalidArgumentException; class NoLock extends Lock implements RefreshableLock diff --git a/src/cache/src/NullStore.php b/src/cache/src/NullStore.php index 045e742c9..95bbb0ed2 100644 --- a/src/cache/src/NullStore.php +++ b/src/cache/src/NullStore.php @@ -4,7 +4,7 @@ namespace Hypervel\Cache; -use Hypervel\Cache\Contracts\LockProvider; +use Hypervel\Contracts\Cache\LockProvider; class NullStore extends TaggableStore implements LockProvider { diff --git a/src/cache/src/RateLimiter.php b/src/cache/src/RateLimiter.php index 1f66940ab..f02a4cac8 100644 --- a/src/cache/src/RateLimiter.php +++ b/src/cache/src/RateLimiter.php @@ -6,7 +6,7 @@ use Closure; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Cache\Contracts\Factory as Cache; +use Hypervel\Contracts\Cache\Factory as Cache; use UnitEnum; use function Hypervel\Support\enum_value; diff --git a/src/cache/src/RedisLock.php b/src/cache/src/RedisLock.php index d304b3093..d17c44faa 100644 --- a/src/cache/src/RedisLock.php +++ b/src/cache/src/RedisLock.php @@ -5,7 +5,7 @@ namespace Hypervel\Cache; use Hyperf\Redis\Redis; -use Hypervel\Cache\Contracts\RefreshableLock; +use Hypervel\Contracts\Cache\RefreshableLock; use InvalidArgumentException; class RedisLock extends Lock implements RefreshableLock diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php index 20a77417f..88b4ecc1c 100644 --- a/src/cache/src/RedisStore.php +++ b/src/cache/src/RedisStore.php @@ -6,7 +6,7 @@ use Hyperf\Redis\RedisFactory; use Hyperf\Redis\RedisProxy; -use Hypervel\Cache\Contracts\LockProvider; +use Hypervel\Contracts\Cache\LockProvider; class RedisStore extends TaggableStore implements LockProvider { diff --git a/src/cache/src/RedisTagSet.php b/src/cache/src/RedisTagSet.php index 9b8ac86a1..5f7077f10 100644 --- a/src/cache/src/RedisTagSet.php +++ b/src/cache/src/RedisTagSet.php @@ -4,8 +4,8 @@ namespace Hypervel\Cache; -use Hyperf\Collection\LazyCollection; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\Store; +use Hypervel\Support\LazyCollection; class RedisTagSet extends TagSet { diff --git a/src/cache/src/RedisTaggedCache.php b/src/cache/src/RedisTaggedCache.php index 89cdcc311..23c2b275e 100644 --- a/src/cache/src/RedisTaggedCache.php +++ b/src/cache/src/RedisTaggedCache.php @@ -6,7 +6,7 @@ use DateInterval; use DateTimeInterface; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\Store; use UnitEnum; use function Hypervel\Support\enum_value; diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 3a469848d..ab83cfc56 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -10,10 +10,7 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Macroable\Macroable; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Cache\Contracts\Repository as CacheContract; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Events\CacheFlushed; use Hypervel\Cache\Events\CacheFlushFailed; use Hypervel\Cache\Events\CacheFlushing; @@ -28,13 +25,16 @@ use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; use Hypervel\Cache\Events\WritingManyKeys; +use Hypervel\Contracts\Cache\Repository as CacheContract; +use Hypervel\Contracts\Cache\Store; +use Hypervel\Support\Traits\Macroable; use Psr\EventDispatcher\EventDispatcherInterface; use UnitEnum; use function Hypervel\Support\enum_value; /** - * @mixin \Hypervel\Cache\Contracts\Store + * @mixin \Hypervel\Contracts\Cache\Store */ class Repository implements ArrayAccess, CacheContract { diff --git a/src/cache/src/StackStore.php b/src/cache/src/StackStore.php index 9a88ab01c..abab9aa8b 100644 --- a/src/cache/src/StackStore.php +++ b/src/cache/src/StackStore.php @@ -6,7 +6,7 @@ use Carbon\Carbon; use Closure; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\Store; class StackStore implements Store { diff --git a/src/cache/src/StackStoreProxy.php b/src/cache/src/StackStoreProxy.php index bf66f9030..1b5feec6e 100644 --- a/src/cache/src/StackStoreProxy.php +++ b/src/cache/src/StackStoreProxy.php @@ -4,7 +4,7 @@ namespace Hypervel\Cache; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\Store; use RuntimeException; class StackStoreProxy implements Store diff --git a/src/cache/src/SwooleStore.php b/src/cache/src/SwooleStore.php index dd30ae62b..20d28e487 100644 --- a/src/cache/src/SwooleStore.php +++ b/src/cache/src/SwooleStore.php @@ -6,7 +6,7 @@ use Carbon\Carbon; use Closure; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\Store; use InvalidArgumentException; use Laravel\SerializableClosure\SerializableClosure; use Swoole\Table; diff --git a/src/cache/src/SwooleTable.php b/src/cache/src/SwooleTable.php index 31a3f8232..45d9048e5 100644 --- a/src/cache/src/SwooleTable.php +++ b/src/cache/src/SwooleTable.php @@ -4,12 +4,10 @@ namespace Hypervel\Cache; -use Hyperf\Collection\Arr; use Hypervel\Cache\Exceptions\ValueTooLargeForColumnException; +use Hypervel\Support\Arr; use Swoole\Table; -use function Hyperf\Collection\collect; - class SwooleTable extends Table { /** diff --git a/src/cache/src/TagSet.php b/src/cache/src/TagSet.php index da5eefe50..0e242f081 100644 --- a/src/cache/src/TagSet.php +++ b/src/cache/src/TagSet.php @@ -4,7 +4,7 @@ namespace Hypervel\Cache; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\Store; class TagSet { diff --git a/src/cache/src/TaggableStore.php b/src/cache/src/TaggableStore.php index 0253351e9..f46d5c93a 100644 --- a/src/cache/src/TaggableStore.php +++ b/src/cache/src/TaggableStore.php @@ -4,7 +4,7 @@ namespace Hypervel\Cache; -use Hypervel\Cache\Contracts\Store; +use Hypervel\Contracts\Cache\Store; abstract class TaggableStore implements Store { diff --git a/src/cache/src/TaggedCache.php b/src/cache/src/TaggedCache.php index a273136ef..aa41af249 100644 --- a/src/cache/src/TaggedCache.php +++ b/src/cache/src/TaggedCache.php @@ -6,9 +6,9 @@ use DateInterval; use DateTimeInterface; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Events\CacheFlushed; use Hypervel\Cache\Events\CacheFlushing; +use Hypervel\Contracts\Cache\Store; use UnitEnum; use function Hypervel\Support\enum_value; diff --git a/src/collections/LICENSE.md b/src/collections/LICENSE.md new file mode 100644 index 000000000..038507e9d --- /dev/null +++ b/src/collections/LICENSE.md @@ -0,0 +1,25 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Copyright (c) Hyperf + +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. \ No newline at end of file diff --git a/src/collections/composer.json b/src/collections/composer.json new file mode 100644 index 000000000..7651704d9 --- /dev/null +++ b/src/collections/composer.json @@ -0,0 +1,49 @@ +{ + "name": "hypervel/collections", + "type": "library", + "description": "The collections package for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "hyperf", + "collections", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Support\\": "src/" + }, + "files": [ + "src/Functions.php", + "src/helpers.php" + ] + }, + "require": { + "php": "^8.2", + "hyperf/conditionable": "~3.1.0", + "hypervel/macroable": "~0.3.0", + "hypervel/contracts": "~0.3.0" + }, + "suggest": { + "hypervel/http": "Required to convert collections to API resources (~0.3.0)." + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.3-dev" + } + } +} diff --git a/src/collections/src/Arr.php b/src/collections/src/Arr.php new file mode 100644 index 000000000..14f4f1528 --- /dev/null +++ b/src/collections/src/Arr.php @@ -0,0 +1,1082 @@ +all(); + } elseif (is_array($values)) { + $results[] = $values; + } + } + + return array_merge([], ...$results); + } + + /** + * Cross join the given arrays, returning all possible permutations. + */ + public static function crossJoin(iterable ...$arrays): array + { + $results = [[]]; + + foreach ($arrays as $index => $array) { + $append = []; + + foreach ($results as $product) { + foreach ($array as $item) { + $product[$index] = $item; + + $append[] = $product; + } + } + + $results = $append; + } + + return $results; + } + + /** + * Divide an array into two arrays. One with keys and the other with values. + */ + public static function divide(array $array): array + { + return [array_keys($array), array_values($array)]; + } + + /** + * Flatten a multi-dimensional associative array with dots. + */ + public static function dot(iterable $array, string $prepend = ''): array + { + $results = []; + + $flatten = function ($data, $prefix) use (&$results, &$flatten): void { + foreach ($data as $key => $value) { + $newKey = $prefix . $key; + + if (is_array($value) && ! empty($value)) { + $flatten($value, $newKey . '.'); + } else { + $results[$newKey] = $value; + } + } + }; + + $flatten($array, $prepend); + + return $results; + } + + /** + * Convert a flatten "dot" notation array into an expanded array. + */ + public static function undot(iterable $array): array + { + $results = []; + + foreach ($array as $key => $value) { + static::set($results, $key, $value); + } + + return $results; + } + + /** + * Get all of the given array except for a specified array of keys. + */ + public static function except(array $array, array|string|int|float $keys): array + { + static::forget($array, $keys); + + return $array; + } + + /** + * Get all of the given array except for a specified array of values. + */ + public static function exceptValues(array $array, mixed $values, bool $strict = false): array + { + $values = (array) $values; + + return array_filter($array, function ($value) use ($values, $strict) { + return ! in_array($value, $values, $strict); + }); + } + + /** + * Determine if the given key exists in the provided array. + */ + public static function exists(ArrayAccess|array $array, string|int|float|null $key): bool + { + if ($array instanceof Enumerable) { + return $array->has($key); + } + + if ($array instanceof ArrayAccess) { + return $array->offsetExists($key); + } + + if (is_float($key) || is_null($key)) { + $key = (string) $key; + } + + return array_key_exists($key, $array); + } + + /** + * Return the first element in an iterable passing a given truth test. + * + * @template TKey + * @template TValue + * @template TFirstDefault + * + * @param iterable $array + * @param null|(callable(TValue, TKey): bool) $callback + * @param (Closure(): TFirstDefault)|TFirstDefault $default + * @return TFirstDefault|TValue + */ + public static function first(iterable $array, ?callable $callback = null, mixed $default = null): mixed + { + if (is_null($callback)) { + if (empty($array)) { + return value($default); + } + + if (is_array($array)) { + return array_first($array); + } + + foreach ($array as $item) { + return $item; + } + + return value($default); + } + + $array = static::from($array); + + $key = array_find_key($array, $callback); + + return $key !== null ? $array[$key] : value($default); + } + + /** + * Return the last element in an array passing a given truth test. + * + * @template TKey + * @template TValue + * @template TLastDefault + * + * @param iterable $array + * @param null|(callable(TValue, TKey): bool) $callback + * @param (Closure(): TLastDefault)|TLastDefault $default + * @return TLastDefault|TValue + */ + public static function last(iterable $array, ?callable $callback = null, mixed $default = null): mixed + { + if (is_null($callback)) { + return empty($array) ? value($default) : array_last($array); + } + + return static::first(array_reverse($array, true), $callback, $default); + } + + /** + * Take the first or last {$limit} items from an array. + */ + public static function take(array $array, int $limit): array + { + if ($limit < 0) { + return array_slice($array, $limit, abs($limit)); + } + + return array_slice($array, 0, $limit); + } + + /** + * Flatten a multi-dimensional array into a single level. + */ + public static function flatten(iterable $array, float $depth = INF): array + { + $result = []; + + foreach ($array as $item) { + $item = $item instanceof Collection ? $item->all() : $item; + + if (! is_array($item)) { + $result[] = $item; + } else { + $values = $depth === 1.0 + ? array_values($item) + : static::flatten($item, $depth - 1); + + foreach ($values as $value) { + $result[] = $value; + } + } + } + + return $result; + } + + /** + * Get a float item from an array using "dot" notation. + * + * @throws InvalidArgumentException + */ + public static function float(ArrayAccess|array $array, string|int|null $key, ?float $default = null): float + { + $value = Arr::get($array, $key, $default); + + if (! is_float($value)) { + throw new InvalidArgumentException( + sprintf('Array value for key [%s] must be a float, %s found.', $key, gettype($value)) + ); + } + + return $value; + } + + /** + * Remove one or many array items from a given array using "dot" notation. + */ + public static function forget(array &$array, array|string|int|float $keys): void + { + $original = &$array; + + $keys = (array) $keys; + + if (count($keys) === 0) { + return; + } + + foreach ($keys as $key) { + // if the exact key exists in the top-level, remove it + if (static::exists($array, $key)) { + unset($array[$key]); + + continue; + } + + $parts = explode('.', $key); + + // clean up before each pass + $array = &$original; + + while (count($parts) > 1) { + $part = array_shift($parts); + + if (isset($array[$part]) && static::accessible($array[$part])) { + $array = &$array[$part]; + } else { + continue 2; + } + } + + unset($array[array_shift($parts)]); + } + } + + /** + * Get the underlying array of items from the given argument. + * + * @template TKey of array-key = array-key + * @template TValue = mixed + * + * @param array|Arrayable|Enumerable|Jsonable|JsonSerializable|object|Traversable|WeakMap $items + * @return ($items is WeakMap ? list : array) + * + * @throws InvalidArgumentException + */ + public static function from(mixed $items): array + { + return match (true) { + is_array($items) => $items, + $items instanceof Enumerable => $items->all(), + $items instanceof Arrayable => $items->toArray(), + $items instanceof WeakMap => iterator_to_array($items, false), + $items instanceof Traversable => iterator_to_array($items), + $items instanceof Jsonable => json_decode($items->toJson(), true), + $items instanceof JsonSerializable => (array) $items->jsonSerialize(), + is_object($items) => (array) $items, // @phpstan-ignore function.alreadyNarrowedType + default => throw new InvalidArgumentException('Items cannot be represented by a scalar value.'), + }; + } + + /** + * Get an item from an array using "dot" notation. + */ + public static function get(ArrayAccess|array|null $array, string|int|null $key, mixed $default = null): mixed + { + if (! static::accessible($array)) { + return value($default); + } + + if (is_null($key)) { + return $array; + } + + if (static::exists($array, $key)) { + return $array[$key]; + } + + if (! str_contains((string) $key, '.')) { + return value($default); + } + + foreach (explode('.', $key) as $segment) { + if (static::accessible($array) && static::exists($array, $segment)) { + $array = $array[$segment]; + } else { + return value($default); + } + } + + return $array; + } + + /** + * Check if an item or items exist in an array using "dot" notation. + */ + public static function has(ArrayAccess|array $array, string|array $keys): bool + { + $keys = (array) $keys; + + if (! $array || $keys === []) { + return false; + } + + foreach ($keys as $key) { + $subKeyArray = $array; + + if (static::exists($array, $key)) { + continue; + } + + foreach (explode('.', $key) as $segment) { + if (static::accessible($subKeyArray) && static::exists($subKeyArray, $segment)) { + $subKeyArray = $subKeyArray[$segment]; + } else { + return false; + } + } + } + + return true; + } + + /** + * Determine if all keys exist in an array using "dot" notation. + */ + public static function hasAll(ArrayAccess|array $array, string|array $keys): bool + { + $keys = (array) $keys; + + if (! $array || $keys === []) { + return false; + } + + foreach ($keys as $key) { + if (! static::has($array, $key)) { + return false; + } + } + + return true; + } + + /** + * Determine if any of the keys exist in an array using "dot" notation. + */ + public static function hasAny(ArrayAccess|array $array, string|array|null $keys): bool + { + if (is_null($keys)) { + return false; + } + + $keys = (array) $keys; + + if (! $array) { + return false; + } + + if ($keys === []) { + return false; + } + + foreach ($keys as $key) { + if (static::has($array, $key)) { + return true; + } + } + + return false; + } + + /** + * Determine if all items pass the given truth test. + */ + public static function every(iterable $array, callable $callback): bool + { + return array_all($array, $callback); + } + + /** + * Determine if some items pass the given truth test. + */ + public static function some(iterable $array, callable $callback): bool + { + return array_any($array, $callback); + } + + /** + * Get an integer item from an array using "dot" notation. + * + * @throws InvalidArgumentException + */ + public static function integer(ArrayAccess|array $array, string|int|null $key, ?int $default = null): int + { + $value = Arr::get($array, $key, $default); + + if (! is_int($value)) { + throw new InvalidArgumentException( + sprintf('Array value for key [%s] must be an integer, %s found.', $key, gettype($value)) + ); + } + + return $value; + } + + /** + * Determines if an array is associative. + * + * An array is "associative" if it doesn't have sequential numerical keys beginning with zero. + */ + public static function isAssoc(array $array): bool + { + return ! array_is_list($array); + } + + /** + * Determines if an array is a list. + * + * An array is a "list" if all array keys are sequential integers starting from 0 with no gaps in between. + */ + public static function isList(array $array): bool + { + return array_is_list($array); + } + + /** + * Join all items using a string. The final items can use a separate glue string. + */ + public static function join(array $array, string $glue, string $finalGlue = ''): string + { + if ($finalGlue === '') { + return implode($glue, $array); + } + + if (count($array) === 0) { + return ''; + } + + if (count($array) === 1) { + return array_last($array); + } + + $finalItem = array_pop($array); + + return implode($glue, $array) . $finalGlue . $finalItem; + } + + /** + * Key an associative array by a field or using a callback. + */ + public static function keyBy(iterable $array, callable|array|string $keyBy): array + { + return (new Collection($array))->keyBy($keyBy)->all(); + } + + /** + * Prepend the key names of an associative array. + */ + public static function prependKeysWith(array $array, string $prependWith): array + { + return static::mapWithKeys($array, fn ($item, $key) => [$prependWith . $key => $item]); + } + + /** + * Get a subset of the items from the given array. + */ + public static function only(array $array, array|string $keys): array + { + return array_intersect_key($array, array_flip((array) $keys)); + } + + /** + * Get a subset of the items from the given array by value. + */ + public static function onlyValues(array $array, mixed $values, bool $strict = false): array + { + $values = (array) $values; + + return array_filter($array, function ($value) use ($values, $strict) { + return in_array($value, $values, $strict); + }); + } + + /** + * Select an array of values from an array. + */ + public static function select(array $array, array|string $keys): array + { + $keys = static::wrap($keys); + + return static::map($array, function ($item) use ($keys) { + $result = []; + + foreach ($keys as $key) { + if (Arr::accessible($item) && Arr::exists($item, $key)) { + $result[$key] = $item[$key]; + } elseif (is_object($item) && isset($item->{$key})) { + $result[$key] = $item->{$key}; + } + } + + return $result; + }); + } + + /** + * 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 + { + $results = []; + + [$value, $key] = static::explodePluckParameters($value, $key); + + foreach ($array as $item) { + $itemValue = $value instanceof Closure + ? $value($item) + : data_get($item, $value); + + // If the key is "null", we will just append the value to the array and keep + // looping. Otherwise we will key the array using the value of the key we + // received from the developer. Then we'll return the final array form. + if (is_null($key)) { + $results[] = $itemValue; + } else { + $itemKey = $key instanceof Closure + ? $key($item) + : data_get($item, $key); + + if (is_object($itemKey) && method_exists($itemKey, '__toString')) { + $itemKey = (string) $itemKey; + } + + $results[$itemKey] = $itemValue; + } + } + + return $results; + } + + /** + * Explode the "value" and "key" arguments passed to "pluck". + * + * @param array|Closure|string $value + * @param null|array|Closure|string $key + * @return array + */ + protected static function explodePluckParameters($value, $key) + { + $value = is_string($value) ? explode('.', $value) : $value; + + $key = is_null($key) || is_array($key) || $key instanceof Closure ? $key : explode('.', $key); + + return [$value, $key]; + } + + /** + * Run a map over each of the items in the array. + */ + public static function map(array $array, callable $callback): array + { + $keys = array_keys($array); + + try { + $items = array_map($callback, $array, $keys); + } catch (ArgumentCountError) { + $items = array_map($callback, $array); + } + + return array_combine($keys, $items); + } + + /** + * Run an associative map over each of the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TKey + * @template TValue + * @template TMapWithKeysKey of array-key + * @template TMapWithKeysValue + * + * @param array $array + * @param callable(TValue, TKey): array $callback + */ + public static function mapWithKeys(array $array, callable $callback): array + { + $result = []; + + foreach ($array as $key => $value) { + $assoc = $callback($value, $key); + + foreach ($assoc as $mapKey => $mapValue) { + $result[$mapKey] = $mapValue; + } + } + + return $result; + } + + /** + * Run a map over each nested chunk of items. + * + * @template TKey + * @template TValue + * + * @param array $array + * @param callable(mixed...): TValue $callback + * @return array + */ + public static function mapSpread(array $array, callable $callback): array + { + return static::map($array, function ($chunk, $key) use ($callback) { + $chunk[] = $key; + + return $callback(...$chunk); + }); + } + + /** + * Push an item onto the beginning of an array. + */ + public static function prepend(array $array, mixed $value, mixed $key = null): array + { + if (func_num_args() == 2) { + array_unshift($array, $value); + } else { + $array = [$key => $value] + $array; + } + + return $array; + } + + /** + * Get a value from the array, and remove it. + */ + public static function pull(array &$array, string|int $key, mixed $default = null): mixed + { + $value = static::get($array, $key, $default); + + static::forget($array, $key); + + return $value; + } + + /** + * Convert the array into a query string. + */ + public static function query(array $array): string + { + return http_build_query($array, '', '&', PHP_QUERY_RFC3986); + } + + /** + * Get one or a specified number of random values from an array. + * + * @throws InvalidArgumentException + */ + public static function random(array $array, ?int $number = null, bool $preserveKeys = false): mixed + { + $requested = is_null($number) ? 1 : $number; + + $count = count($array); + + if ($requested > $count) { + throw new InvalidArgumentException( + "You requested {$requested} items, but there are only {$count} items available." + ); + } + + if (empty($array) || (! is_null($number) && $number <= 0)) { + return is_null($number) ? null : []; + } + + $keys = (new Randomizer())->pickArrayKeys($array, $requested); + + if (is_null($number)) { + return $array[$keys[0]]; + } + + $results = []; + + if ($preserveKeys) { + foreach ($keys as $key) { + $results[$key] = $array[$key]; + } + } else { + foreach ($keys as $key) { + $results[] = $array[$key]; + } + } + + return $results; + } + + /** + * Set an array item to a given value using "dot" notation. + * + * If no key is given to the method, the entire array will be replaced. + */ + public static function set(array &$array, string|int|null $key, mixed $value): array + { + if (is_null($key)) { + return $array = $value; + } + + $keys = explode('.', (string) $key); + + foreach ($keys as $i => $key) { + if (count($keys) === 1) { + break; + } + + unset($keys[$i]); + + // If the key doesn't exist at this depth, we will just create an empty array + // to hold the next value, allowing us to create the arrays to hold final + // values at the correct depth. Then we'll keep digging into the array. + if (! isset($array[$key]) || ! is_array($array[$key])) { + $array[$key] = []; + } + + $array = &$array[$key]; + } + + $array[array_shift($keys)] = $value; + + return $array; + } + + /** + * Push an item into an array using "dot" notation. + */ + public static function push(ArrayAccess|array &$array, string|int|null $key, mixed ...$values): array + { + $target = static::array($array, $key, []); + + array_push($target, ...$values); + + return static::set($array, $key, $target); + } + + /** + * Shuffle the given array and return the result. + */ + public static function shuffle(array $array): array + { + return (new Randomizer())->shuffleArray($array); + } + + /** + * Get the first item in the array, but only if exactly one item exists. Otherwise, throw an exception. + * + * @throws ItemNotFoundException + * @throws MultipleItemsFoundException + */ + public static function sole(array $array, ?callable $callback = null): mixed + { + if ($callback) { + $array = static::where($array, $callback); + } + + $count = count($array); + + if ($count === 0) { + throw new ItemNotFoundException(); + } + + if ($count > 1) { + throw new MultipleItemsFoundException($count); + } + + return static::first($array); + } + + /** + * Sort the array using the given callback or "dot" notation. + */ + public static function sort(iterable $array, callable|array|string|null $callback = null): array + { + return (new Collection($array))->sortBy($callback)->all(); + } + + /** + * Sort the array in descending order using the given callback or "dot" notation. + */ + public static function sortDesc(iterable $array, callable|array|string|null $callback = null): array + { + return (new Collection($array))->sortByDesc($callback)->all(); + } + + /** + * Recursively sort an array by keys and values. + */ + public static function sortRecursive(array $array, int $options = SORT_REGULAR, bool $descending = false): array + { + foreach ($array as &$value) { + if (is_array($value)) { + $value = static::sortRecursive($value, $options, $descending); + } + } + + if (! array_is_list($array)) { + $descending + ? krsort($array, $options) + : ksort($array, $options); + } else { + $descending + ? rsort($array, $options) + : sort($array, $options); + } + + return $array; + } + + /** + * Recursively sort an array by keys and values in descending order. + */ + public static function sortRecursiveDesc(array $array, int $options = SORT_REGULAR): array + { + return static::sortRecursive($array, $options, true); + } + + /** + * Get a string item from an array using "dot" notation. + * + * @throws InvalidArgumentException + */ + public static function string(ArrayAccess|array $array, string|int|null $key, ?string $default = null): string + { + $value = Arr::get($array, $key, $default); + + if (! is_string($value)) { + throw new InvalidArgumentException( + sprintf('Array value for key [%s] must be a string, %s found.', $key, gettype($value)) + ); + } + + return $value; + } + + /** + * Conditionally compile classes from an array into a CSS class list. + */ + public static function toCssClasses(array|string $array): string + { + $classList = static::wrap($array); + + $classes = []; + + foreach ($classList as $class => $constraint) { + if (is_numeric($class)) { + $classes[] = $constraint; + } elseif ($constraint) { + $classes[] = $class; + } + } + + return implode(' ', $classes); + } + + /** + * Conditionally compile styles from an array into a style list. + */ + public static function toCssStyles(array|string $array): string + { + $styleList = static::wrap($array); + + $styles = []; + + foreach ($styleList as $class => $constraint) { + if (is_numeric($class)) { + $styles[] = Str::finish($constraint, ';'); + } elseif ($constraint) { + $styles[] = Str::finish($class, ';'); + } + } + + return implode(' ', $styles); + } + + /** + * Filter the array using the given callback. + */ + public static function where(array $array, callable $callback): array + { + return array_filter($array, $callback, ARRAY_FILTER_USE_BOTH); + } + + /** + * Filter the array using the negation of the given callback. + */ + public static function reject(array $array, callable $callback): array + { + return static::where($array, fn ($value, $key) => ! $callback($value, $key)); + } + + /** + * Partition the array into two arrays using the given callback. + * + * @template TKey of array-key + * @template TValue of mixed + * + * @param iterable $array + * @param callable(TValue, TKey): bool $callback + * @return array, array> + */ + public static function partition(iterable $array, callable $callback): array + { + $passed = []; + $failed = []; + + foreach ($array as $key => $item) { + if ($callback($item, $key)) { + $passed[$key] = $item; + } else { + $failed[$key] = $item; + } + } + + return [$passed, $failed]; + } + + /** + * Filter items where the value is not null. + */ + public static function whereNotNull(array $array): array + { + return static::where($array, fn ($value) => ! is_null($value)); + } + + /** + * If the given value is not an array and not null, wrap it in one. + */ + public static function wrap(mixed $value): array + { + if (is_null($value)) { + return []; + } + + return is_array($value) ? $value : [$value]; + } +} diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php new file mode 100644 index 000000000..f81b3ab46 --- /dev/null +++ b/src/collections/src/Collection.php @@ -0,0 +1,1892 @@ + + * @implements \Hypervel\Support\Enumerable + */ +class Collection implements ArrayAccess, CanBeEscapedWhenCastToString, Enumerable +{ + /** + * @use \Hypervel\Support\Traits\EnumeratesValues + */ + use EnumeratesValues; + + use Macroable; + use TransformsToResourceCollection; + + /** + * The items contained in the collection. + * + * @var array + */ + protected array $items = []; + + /** + * Create a new collection. + * + * @param null|Arrayable|iterable $items + */ + public function __construct($items = []) + { + $this->items = $this->getArrayableItems($items); + } + + /** + * Create a collection with the given range. + * + * @return static + */ + public static function range(int $from, int $to, int $step = 1): static + { + return new static(range($from, $to, $step)); + } + + /** + * Get all of the items in the collection. + * + * @return array + */ + public function all(): array + { + return $this->items; + } + + /** + * Get a lazy collection for the items in this collection. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazy(): LazyCollection + { + return new LazyCollection($this->items); + } + + /** + * Get the median of a given key. + * + * @param null|array|string $key + */ + public function median(string|array|null $key = null): float|int|null + { + $values = (isset($key) ? $this->pluck($key) : $this) + ->reject(fn ($item) => is_null($item)) + ->sort()->values(); + + $count = $values->count(); + + if ($count === 0) { + return null; + } + + $middle = intdiv($count, 2); + + if ($count % 2) { + return $values->get($middle); + } + + return (new static([ + $values->get($middle - 1), $values->get($middle), + ]))->average(); + } + + /** + * Get the mode of a given key. + * + * @param null|array|string $key + * @return null|array + */ + public function mode(string|array|null $key = null): ?array + { + if ($this->count() === 0) { + return null; + } + + $collection = isset($key) ? $this->pluck($key) : $this; + + $counts = new static(); + + // @phpstan-ignore offsetAssign.valueType (PHPStan infers empty collection as Collection<*NEVER*, *NEVER*>) + $collection->each(fn ($value) => $counts[$value] = isset($counts[$value]) ? $counts[$value] + 1 : 1); + + $sorted = $counts->sort(); + + $highestValue = $sorted->last(); + + return $sorted->filter(fn ($value) => $value == $highestValue) + ->sort()->keys()->all(); + } + + /** + * Collapse the collection of items into a single array. + * + * @return static + */ + public function collapse() + { + return new static(Arr::collapse($this->items)); + } + + /** + * Collapse the collection of items into a single array while preserving its keys. + * + * @return static + */ + public function collapseWithKeys(): static + { + if (! $this->items) { + return new static(); + } + + $results = []; + + foreach ($this->items as $key => $values) { + if ($values instanceof Collection) { + $values = $values->all(); + } elseif (! is_array($values)) { + continue; + } + + $results[$key] = $values; + } + + if (! $results) { + return new static(); + } + + return new static(array_replace(...$results)); + } + + /** + * Determine if an item exists in the collection. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + */ + public function contains(mixed $key, mixed $operator = null, mixed $value = null): bool + { + if (func_num_args() === 1) { + if ($this->useAsCallable($key)) { + return array_any($this->items, $key); + } + + return in_array($key, $this->items); + } + + return $this->contains($this->operatorForWhere(...func_get_args())); + } + + /** + * Determine if an item exists, using strict comparison. + * + * @param array-key|(callable(TValue): bool)|TValue $key + * @param null|TValue $value + */ + public function containsStrict(mixed $key, mixed $value = null): bool + { + if (func_num_args() === 2) { + return $this->contains(fn ($item) => data_get($item, $key) === $value); + } + + if ($this->useAsCallable($key)) { + return ! is_null($this->first($key)); + } + + return in_array($key, $this->items, true); + } + + /** + * Determine if an item is not contained in the collection. + */ + public function doesntContain(mixed $key, mixed $operator = null, mixed $value = null): bool + { + return ! $this->contains(...func_get_args()); + } + + /** + * Determine if an item is not contained in the enumerable, using strict comparison. + */ + public function doesntContainStrict(mixed $key, mixed $operator = null, mixed $value = null): bool + { + return ! $this->containsStrict(...func_get_args()); + } + + /** + * Cross join with the given lists, returning all possible permutations. + * + * @template TCrossJoinKey of array-key + * @template TCrossJoinValue + * + * @param Arrayable|iterable ...$lists + * @return static> + */ + public function crossJoin(mixed ...$lists): static + { + return new static(Arr::crossJoin( + $this->items, + ...array_map($this->getArrayableItems(...), $lists) + )); + } + + /** + * Get the items in the collection that are not present in the given items. + * + * @param Arrayable|iterable $items + */ + public function diff(mixed $items): static + { + return new static(array_diff($this->items, $this->getArrayableItems($items))); + } + + /** + * Get the items in the collection that are not present in the given items, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + */ + public function diffUsing(mixed $items, callable $callback): static + { + return new static(array_udiff($this->items, $this->getArrayableItems($items), $callback)); + } + + /** + * Get the items in the collection whose keys and values are not present in the given items. + * + * @param Arrayable|iterable $items + */ + public function diffAssoc(mixed $items): static + { + return new static(array_diff_assoc($this->items, $this->getArrayableItems($items))); + } + + /** + * Get the items in the collection whose keys and values are not present in the given items, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TKey, TKey): int $callback + */ + public function diffAssocUsing(mixed $items, callable $callback): static + { + return new static(array_diff_uassoc($this->items, $this->getArrayableItems($items), $callback)); + } + + /** + * Get the items in the collection whose keys are not present in the given items. + * + * @param Arrayable|iterable $items + */ + public function diffKeys(mixed $items): static + { + return new static(array_diff_key($this->items, $this->getArrayableItems($items))); + } + + /** + * Get the items in the collection whose keys are not present in the given items, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TKey, TKey): int $callback + */ + public function diffKeysUsing(mixed $items, callable $callback): static + { + return new static(array_diff_ukey($this->items, $this->getArrayableItems($items), $callback)); + } + + /** + * Retrieve duplicate items from the collection. + * + * @template TMapValue + * + * @param null|(callable(TValue): TMapValue)|string $callback + */ + public function duplicates(callable|string|null $callback = null, bool $strict = false): static + { + $items = $this->map($this->valueRetriever($callback)); + + $uniqueItems = $items->unique(null, $strict); + + $compare = $this->duplicateComparator($strict); + + $duplicates = new static(); + + foreach ($items as $key => $value) { + if ($uniqueItems->isNotEmpty() && $compare($value, $uniqueItems->first())) { + $uniqueItems->shift(); + } else { + $duplicates[$key] = $value; + } + } + + return $duplicates; + } + + /** + * Retrieve duplicate items from the collection using strict comparison. + * + * @template TMapValue + * + * @param null|(callable(TValue): TMapValue)|string $callback + */ + public function duplicatesStrict(callable|string|null $callback = null): static + { + return $this->duplicates($callback, true); + } + + /** + * Get the comparison function to detect duplicates. + * + * @return callable(TValue, TValue): bool + */ + protected function duplicateComparator(bool $strict): callable + { + if ($strict) { + return fn ($a, $b) => $a === $b; + } + + return fn ($a, $b) => $a == $b; + } + + /** + * Get all items except for those with the specified keys. + * + * @param null|array|Enumerable|string $keys + */ + public function except(mixed $keys): static + { + if (is_null($keys)) { + return new static($this->items); + } + + if ($keys instanceof Enumerable) { + $keys = $keys->all(); + } elseif (! is_array($keys)) { + $keys = func_get_args(); + } + + return new static(Arr::except($this->items, $keys)); + } + + /** + * Run a filter over each of the items. + * + * @param null|(callable(TValue, TKey): bool) $callback + */ + public function filter(?callable $callback = null): static + { + if ($callback) { + return new static(Arr::where($this->items, $callback)); + } + + return new static(array_filter($this->items)); + } + + /** + * Get the first item from the collection passing the given truth test. + * + * @template TFirstDefault + * + * @param null|(callable(TValue, TKey): bool) $callback + * @param (Closure(): TFirstDefault)|TFirstDefault $default + * @return TFirstDefault|TValue + */ + public function first(?callable $callback = null, mixed $default = null): mixed + { + return Arr::first($this->items, $callback, $default); + } + + /** + * Get a flattened array of the items in the collection. + * + * @return static + */ + public function flatten(int|float $depth = INF) + { + return new static(Arr::flatten($this->items, $depth)); + } + + /** + * Flip the items in the collection. + * + * @return static + * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + */ + public function flip() + { + return new static(array_flip($this->items)); + } + + /** + * Remove an item from the collection by key. + * + * @param Arrayable|iterable|TKey $keys + * @return $this + */ + public function forget(mixed $keys): static + { + foreach ($this->getArrayableItems($keys) as $key) { + $this->offsetUnset($key); + } + + return $this; + } + + /** + * Get an item from the collection by key. + * + * @template TGetDefault + * + * @param null|TKey $key + * @param (Closure(): TGetDefault)|TGetDefault $default + * @return TGetDefault|TValue + */ + public function get(mixed $key, mixed $default = null): mixed + { + $key ??= ''; + + if (array_key_exists($key, $this->items)) { + return $this->items[$key]; + } + + return value($default); + } + + /** + * Get an item from the collection by key or add it to collection if it does not exist. + * + * @template TGetOrPutValue + * + * @param (Closure(): TGetOrPutValue)|TGetOrPutValue $value + * @return TGetOrPutValue|TValue + */ + public function getOrPut(mixed $key, mixed $value): mixed + { + if (array_key_exists($key ?? '', $this->items)) { + return $this->items[$key ?? '']; + } + + $this->offsetSet($key, $value = value($value)); + + return $value; + } + + /** + * Group an associative array by a field or using a callback. + * + * @template TGroupKey of array-key|\UnitEnum|\Stringable + * + * @param array|(callable(TValue, TKey): TGroupKey)|string $groupBy + * @return static< + * ($groupBy is (array|string) + * ? array-key + * : (TGroupKey is \UnitEnum ? array-key : (TGroupKey is \Stringable ? string : TGroupKey))), + * static<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)> + * > + * @phpstan-ignore method.childReturnType, generics.notSubtype, return.type (complex conditional types PHPStan can't match) + */ + public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static + { + if (! $this->useAsCallable($groupBy) && is_array($groupBy)) { + $nextGroups = $groupBy; + + $groupBy = array_shift($nextGroups); + } + + $groupBy = $this->valueRetriever($groupBy); + + $results = []; + + foreach ($this->items as $key => $value) { + $groupKeys = $groupBy($value, $key); + + if (! is_array($groupKeys)) { + $groupKeys = [$groupKeys]; + } + + foreach ($groupKeys as $groupKey) { + $groupKey = match (true) { + is_bool($groupKey) => (int) $groupKey, + $groupKey instanceof UnitEnum => enum_value($groupKey), + $groupKey instanceof Stringable => (string) $groupKey, + is_null($groupKey) => (string) $groupKey, + default => $groupKey, + }; + + if (! array_key_exists($groupKey, $results)) { + $results[$groupKey] = new static(); + } + + $results[$groupKey]->offsetSet($preserveKeys ? $key : null, $value); + } + } + + $result = new static($results); + + if (! empty($nextGroups)) { + // @phpstan-ignore return.type (recursive groupBy returns Enumerable, PHPStan can't verify it matches static) + return $result->map->groupBy($nextGroups, $preserveKeys); + } + + return $result; + } + + /** + * Key an associative array by a field or using a callback. + * + * @template TNewKey of array-key|\UnitEnum + * + * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is UnitEnum ? array-key : TNewKey)), TValue> + * @phpstan-ignore method.childReturnType (complex conditional types PHPStan can't match) + */ + public function keyBy(callable|array|string $keyBy): static + { + $keyBy = $this->valueRetriever($keyBy); + + $results = []; + + foreach ($this->items as $key => $item) { + $resolvedKey = $keyBy($item, $key); + + if ($resolvedKey instanceof UnitEnum) { + $resolvedKey = enum_value($resolvedKey); + } + + if (is_object($resolvedKey)) { + $resolvedKey = (string) $resolvedKey; + } + + $results[$resolvedKey] = $item; + } + + return new static($results); + } + + /** + * Determine if an item exists in the collection by key. + * + * @param array|TKey $key + */ + public function has(mixed $key): bool + { + $keys = is_array($key) ? $key : func_get_args(); + + return array_all($keys, fn ($key) => array_key_exists($key ?? '', $this->items)); + } + + /** + * Determine if any of the keys exist in the collection. + * + * @param array|TKey $key + */ + public function hasAny(mixed $key): bool + { + if ($this->isEmpty()) { + return false; + } + + $keys = is_array($key) ? $key : func_get_args(); + + return array_any($keys, fn ($key) => array_key_exists($key ?? '', $this->items)); + } + + /** + * Concatenate values of a given key as a string. + * + * @param null|(callable(TValue, TKey): mixed)|string $value + */ + public function implode(callable|string|null $value, ?string $glue = null): string + { + if ($this->useAsCallable($value)) { + return implode($glue ?? '', $this->map($value)->all()); + } + + $first = $this->first(); + + if (is_array($first) || (is_object($first) && ! $first instanceof Stringable)) { + return implode($glue ?? '', $this->pluck($value)->all()); + } + + return implode($value ?? '', $this->items); + } + + /** + * Intersect the collection with the given items. + * + * @param Arrayable|iterable $items + */ + public function intersect(mixed $items): static + { + return new static(array_intersect($this->items, $this->getArrayableItems($items))); + } + + /** + * Intersect the collection with the given items, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + */ + public function intersectUsing(mixed $items, callable $callback): static + { + return new static(array_uintersect($this->items, $this->getArrayableItems($items), $callback)); + } + + /** + * Intersect the collection with the given items with additional index check. + * + * @param Arrayable|iterable $items + */ + public function intersectAssoc(mixed $items): static + { + return new static(array_intersect_assoc($this->items, $this->getArrayableItems($items))); + } + + /** + * Intersect the collection with the given items with additional index check, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + */ + public function intersectAssocUsing(mixed $items, callable $callback): static + { + return new static(array_intersect_uassoc($this->items, $this->getArrayableItems($items), $callback)); + } + + /** + * Intersect the collection with the given items by key. + * + * @param Arrayable|iterable $items + */ + public function intersectByKeys(mixed $items): static + { + return new static(array_intersect_key( + $this->items, + $this->getArrayableItems($items) + )); + } + + /** + * Determine if the collection is empty or not. + * + * @phpstan-assert-if-true null $this->first() + * @phpstan-assert-if-true null $this->last() + * + * @phpstan-assert-if-false TValue $this->first() + * @phpstan-assert-if-false TValue $this->last() + */ + public function isEmpty(): bool + { + return empty($this->items); + } + + /** + * Determine if the collection contains exactly one item. If a callback is provided, determine if exactly one item matches the condition. + * + * @param null|(callable(TValue, TKey): bool) $callback + */ + public function containsOneItem(?callable $callback = null): bool + { + if ($callback) { + return $this->filter($callback)->count() === 1; + } + + return $this->count() === 1; + } + + /** + * Determine if the collection contains multiple items. If a callback is provided, determine if multiple items match the condition. + * + * @param null|(callable(TValue, TKey): bool) $callback + */ + public function containsManyItems(?callable $callback = null): bool + { + if (! $callback) { + return $this->count() > 1; + } + + $count = 0; + + foreach ($this as $key => $item) { + if ($callback($item, $key)) { + ++$count; + } + + if ($count > 1) { + return true; + } + } + + return false; + } + + /** + * Join all items from the collection using a string. The final items can use a separate glue string. + */ + public function join(string $glue, string $finalGlue = ''): string + { + if ($finalGlue === '') { + return $this->implode($glue); + } + + $count = $this->count(); + + if ($count === 0) { + return ''; + } + + if ($count === 1) { + return (string) $this->last(); + } + + $collection = new static($this->items); + + $finalItem = $collection->pop(); + + return $collection->implode($glue) . $finalGlue . $finalItem; + } + + /** + * Get the keys of the collection items. + * + * @return static + */ + public function keys() + { + return new static(array_keys($this->items)); + } + + /** + * Get the last item from the collection. + * + * @template TLastDefault + * + * @param null|(callable(TValue, TKey): bool) $callback + * @param (Closure(): TLastDefault)|TLastDefault $default + * @return TLastDefault|TValue + */ + public function last(?callable $callback = null, mixed $default = null): mixed + { + return Arr::last($this->items, $callback, $default); + } + + /** + * Get the values of a given key. + * + * @param null|array|Closure|int|string $value + * @return static + */ + public function pluck(Closure|string|int|array|null $value, Closure|string|null $key = null) + { + return new static(Arr::pluck($this->items, $value, $key)); + } + + /** + * Run a map over each of the items. + * + * @template TMapValue + * + * @param callable(TValue, TKey): TMapValue $callback + * @return static + */ + public function map(callable $callback) + { + return new static(Arr::map($this->items, $callback)); + } + + /** + * Run a dictionary map over the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TMapToDictionaryKey of array-key + * @template TMapToDictionaryValue + * + * @param callable(TValue, TKey): array $callback + * @return static> + */ + public function mapToDictionary(callable $callback): static + { + $dictionary = []; + + foreach ($this->items as $key => $item) { + $pair = $callback($item, $key); + + $key = key($pair); + + $value = reset($pair); + + if (! isset($dictionary[$key])) { + $dictionary[$key] = []; + } + + $dictionary[$key][] = $value; + } + + return new static($dictionary); + } + + /** + * Run an associative map over each of the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TMapWithKeysKey of array-key + * @template TMapWithKeysValue + * + * @param callable(TValue, TKey): array $callback + * @return static + */ + public function mapWithKeys(callable $callback) + { + return new static(Arr::mapWithKeys($this->items, $callback)); + } + + /** + * Merge the collection with the given items. + * + * @template TMergeValue + * + * @param Arrayable|iterable $items + * @return static + */ + public function merge(mixed $items): static + { + return new static(array_merge($this->items, $this->getArrayableItems($items))); + } + + /** + * Recursively merge the collection with the given items. + * + * @template TMergeRecursiveValue + * + * @param Arrayable|iterable $items + * @return static + */ + public function mergeRecursive(mixed $items): static + { + return new static(array_merge_recursive($this->items, $this->getArrayableItems($items))); + } + + /** + * Multiply the items in the collection by the multiplier. + */ + public function multiply(int $multiplier): static + { + $new = new static(); + + for ($i = 0; $i < $multiplier; ++$i) { + $new->push(...$this->items); + } + + return $new; + } + + /** + * Create a collection by using this collection for keys and another for its values. + * + * @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) + */ + public function combine(mixed $values): static + { + return new static(array_combine($this->all(), $this->getArrayableItems($values))); + } + + /** + * Union the collection with the given items. + * + * @param Arrayable|iterable $items + */ + public function union(mixed $items): static + { + return new static($this->items + $this->getArrayableItems($items)); + } + + /** + * Create a new collection consisting of every n-th element. + * + * @throws InvalidArgumentException + */ + public function nth(int $step, int $offset = 0): static + { + if ($step < 1) { + throw new InvalidArgumentException('Step value must be at least 1.'); + } + + $new = []; + + $position = 0; + + foreach ($this->slice($offset)->items as $item) { + if ($position % $step === 0) { + $new[] = $item; + } + + ++$position; + } + + return new static($new); + } + + /** + * Get the items with the specified keys. + * + * @param null|array|Enumerable|string $keys + */ + public function only(mixed $keys): static + { + if (is_null($keys)) { + return new static($this->items); + } + + if ($keys instanceof Enumerable) { + $keys = $keys->all(); + } + + $keys = is_array($keys) ? $keys : func_get_args(); + + return new static(Arr::only($this->items, $keys)); + } + + /** + * Select specific values from the items within the collection. + * + * @param null|array|Enumerable|string $keys + */ + public function select(mixed $keys): static + { + if (is_null($keys)) { + return new static($this->items); + } + + if ($keys instanceof Enumerable) { + $keys = $keys->all(); + } + + $keys = is_array($keys) ? $keys : func_get_args(); + + return new static(Arr::select($this->items, $keys)); + } + + /** + * Get and remove the last N items from the collection. + * + * @return ($count is 1 ? null|TValue : static) + */ + public function pop(int $count = 1): mixed + { + if ($count < 1) { + return new static(); + } + + if ($count === 1) { + return array_pop($this->items); + } + + if ($this->isEmpty()) { + return new static(); + } + + $results = []; + + $collectionCount = $this->count(); + + foreach (range(1, min($count, $collectionCount)) as $item) { + $results[] = array_pop($this->items); + } + + return new static($results); + } + + /** + * Push an item onto the beginning of the collection. + * + * @param TValue $value + * @param TKey $key + * @return $this + */ + public function prepend(mixed $value, mixed $key = null): static + { + $this->items = Arr::prepend($this->items, ...(func_num_args() > 1 ? func_get_args() : [$value])); + + return $this; + } + + /** + * Push one or more items onto the end of the collection. + * + * @param TValue ...$values + * @return $this + */ + public function push(mixed ...$values): static + { + foreach ($values as $value) { + $this->items[] = $value; + } + + return $this; + } + + /** + * Prepend one or more items to the beginning of the collection. + * + * @param TValue ...$values + * @return $this + */ + public function unshift(mixed ...$values): static + { + array_unshift($this->items, ...$values); + + return $this; + } + + /** + * Push all of the given items onto the collection. + * + * @template TConcatKey of array-key + * @template TConcatValue + * + * @param iterable $source + * @return static + */ + public function concat(iterable $source): static + { + $result = new static($this); + + foreach ($source as $item) { + $result->push($item); + } + + return $result; + } + + /** + * Get and remove an item from the collection. + * + * @template TPullDefault + * + * @param TKey $key + * @param (Closure(): TPullDefault)|TPullDefault $default + * @return TPullDefault|TValue + */ + public function pull(mixed $key, mixed $default = null): mixed + { + return Arr::pull($this->items, $key, $default); + } + + /** + * Put an item in the collection by key. + * + * @param TKey $key + * @param TValue $value + * @return $this + */ + public function put(mixed $key, mixed $value): static + { + $this->offsetSet($key, $value); + + return $this; + } + + /** + * Get one or a specified number of items randomly from the collection. + * + * @param null|(callable(self): int)|int $number + * @return ($number is null ? TValue : static) + * + * @throws InvalidArgumentException + */ + public function random(callable|int|null $number = null, bool $preserveKeys = false): mixed + { + if (is_null($number)) { + return Arr::random($this->items); + } + + if (is_callable($number)) { + return new static(Arr::random($this->items, $number($this), $preserveKeys)); + } + + return new static(Arr::random($this->items, $number, $preserveKeys)); + } + + /** + * Replace the collection items with the given items. + * + * @param Arrayable|iterable $items + */ + public function replace(mixed $items): static + { + return new static(array_replace($this->items, $this->getArrayableItems($items))); + } + + /** + * Recursively replace the collection items with the given items. + * + * @param Arrayable|iterable $items + */ + public function replaceRecursive(mixed $items): static + { + return new static(array_replace_recursive($this->items, $this->getArrayableItems($items))); + } + + /** + * Reverse items order. + */ + public function reverse(): static + { + return new static(array_reverse($this->items, true)); + } + + /** + * Search the collection for a given value and return the corresponding key if successful. + * + * @param (callable(TValue,TKey): bool)|TValue $value + * @return false|TKey + */ + public function search(mixed $value, bool $strict = false): mixed + { + if (! $this->useAsCallable($value)) { + return array_search($value, $this->items, $strict); + } + + return array_find_key($this->items, $value) ?? false; + } + + /** + * Get the item before the given item. + * + * @param (callable(TValue,TKey): bool)|TValue $value + * @return null|TValue + */ + public function before(mixed $value, bool $strict = false): mixed + { + $key = $this->search($value, $strict); + + if ($key === false) { + return null; + } + + $position = ($keys = $this->keys())->search($key); + + if ($position === 0) { + return null; + } + + return $this->get($keys->get($position - 1)); + } + + /** + * Get the item after the given item. + * + * @param (callable(TValue,TKey): bool)|TValue $value + * @return null|TValue + */ + public function after(mixed $value, bool $strict = false): mixed + { + $key = $this->search($value, $strict); + + if ($key === false) { + return null; + } + + $position = ($keys = $this->keys())->search($key); + + if ($position === $keys->count() - 1) { + return null; + } + + return $this->get($keys->get($position + 1)); + } + + /** + * Get and remove the first N items from the collection. + * + * @param int<0, max> $count + * @return ($count is 1 ? null|TValue : static) + * + * @throws InvalidArgumentException + */ + public function shift(int $count = 1): mixed + { + // @phpstan-ignore smaller.alwaysFalse (defensive validation - native int type allows negative values) + if ($count < 0) { + throw new InvalidArgumentException('Number of shifted items may not be less than zero.'); + } + + if ($this->isEmpty()) { + return null; + } + + if ($count === 0) { + return new static(); + } + + if ($count === 1) { + return array_shift($this->items); + } + + $results = []; + + $collectionCount = $this->count(); + + foreach (range(1, min($count, $collectionCount)) as $item) { + $results[] = array_shift($this->items); + } + + return new static($results); + } + + /** + * Shuffle the items in the collection. + */ + public function shuffle(): static + { + return new static(Arr::shuffle($this->items)); + } + + /** + * Create chunks representing a "sliding window" view of the items in the collection. + * + * @param positive-int $size + * @param positive-int $step + * @return static + * + * @throws InvalidArgumentException + */ + public function sliding(int $size = 2, int $step = 1): static + { + // @phpstan-ignore smaller.alwaysFalse (defensive validation - native int type allows non-positive values) + if ($size < 1) { + throw new InvalidArgumentException('Size value must be at least 1.'); + } + if ($step < 1) { // @phpstan-ignore smaller.alwaysFalse + throw new InvalidArgumentException('Step value must be at least 1.'); + } + + $chunks = (int) floor(($this->count() - $size) / $step) + 1; + + return static::times($chunks, fn ($number) => $this->slice(($number - 1) * $step, $size)); + } + + /** + * Skip the first {$count} items. + */ + public function skip(int $count): static + { + return $this->slice($count); + } + + /** + * Skip items in the collection until the given condition is met. + * + * @param callable(TValue,TKey): bool|TValue $value + */ + public function skipUntil(mixed $value): static + { + return new static($this->lazy()->skipUntil($value)->all()); + } + + /** + * Skip items in the collection while the given condition is met. + * + * @param callable(TValue,TKey): bool|TValue $value + */ + public function skipWhile(mixed $value): static + { + return new static($this->lazy()->skipWhile($value)->all()); + } + + /** + * Slice the underlying collection array. + */ + public function slice(int $offset, ?int $length = null): static + { + return new static(array_slice($this->items, $offset, $length, true)); + } + + /** + * Split a collection into a certain number of groups. + * + * @return static + * + * @throws InvalidArgumentException + */ + public function split(int $numberOfGroups): static + { + if ($numberOfGroups < 1) { + throw new InvalidArgumentException('Number of groups must be at least 1.'); + } + + if ($this->isEmpty()) { + return new static(); + } + + $groups = new static(); + + $groupSize = (int) floor($this->count() / $numberOfGroups); + + $remain = $this->count() % $numberOfGroups; + + $start = 0; + + for ($i = 0; $i < $numberOfGroups; ++$i) { + $size = $groupSize; + + if ($i < $remain) { + ++$size; + } + + if ($size) { + $groups->push(new static(array_slice($this->items, $start, $size))); + + $start += $size; + } + } + + return $groups; + } + + /** + * Split a collection into a certain number of groups, and fill the first groups completely. + * + * @return static + * + * @throws InvalidArgumentException + */ + public function splitIn(int $numberOfGroups): static + { + if ($numberOfGroups < 1) { + throw new InvalidArgumentException('Number of groups must be at least 1.'); + } + + return $this->chunk((int) ceil($this->count() / $numberOfGroups)); + } + + /** + * Get the first item in the collection, but only if exactly one item exists. Otherwise, throw an exception. + * + * @param null|(callable(TValue, TKey): bool)|string $key + * @return TValue + * + * @throws ItemNotFoundException + * @throws MultipleItemsFoundException + */ + public function sole(callable|string|null $key = null, mixed $operator = null, mixed $value = null): mixed + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + $items = $this->unless($filter == null)->filter($filter); + + $count = $items->count(); + + if ($count === 0) { + throw new ItemNotFoundException(); + } + + if ($count > 1) { + throw new MultipleItemsFoundException($count); + } + + return $items->first(); + } + + /** + * Get the first item in the collection but throw an exception if no matching items exist. + * + * @param (callable(TValue, TKey): bool)|string $key + * @return TValue + * + * @throws ItemNotFoundException + */ + public function firstOrFail(callable|string|null $key = null, mixed $operator = null, mixed $value = null): mixed + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + $placeholder = new stdClass(); + + $item = $this->first($filter, $placeholder); + + if ($item === $placeholder) { + throw new ItemNotFoundException(); + } + + return $item; + } + + /** + * Chunk the collection into chunks of the given size. + * + * @return ($preserveKeys is true ? static : static>) + */ + public function chunk(int $size, bool $preserveKeys = true): static + { + if ($size <= 0) { + return new static(); + } + + $chunks = []; + + foreach (array_chunk($this->items, $size, $preserveKeys) as $chunk) { + $chunks[] = new static($chunk); + } + + return new static($chunks); + } + + /** + * Chunk the collection into chunks with a callback. + * + * @param callable(TValue, TKey, static): bool $callback + * @return static> + */ + public function chunkWhile(callable $callback): static + { + return new static( + // @phpstan-ignore argument.type (callback typed for Collection but passed to LazyCollection) + $this->lazy()->chunkWhile($callback)->mapInto(static::class) + ); + } + + /** + * Sort through each item with a callback. + * + * @param null|(callable(TValue, TValue): int)|int $callback + */ + public function sort(callable|int|null $callback = null): static + { + $items = $this->items; + + $callback && is_callable($callback) + ? uasort($items, $callback) + : asort($items, $callback ?? SORT_REGULAR); + + return new static($items); + } + + /** + * Sort items in descending order. + */ + public function sortDesc(int $options = SORT_REGULAR): static + { + $items = $this->items; + + arsort($items, $options); + + return new static($items); + } + + /** + * Sort the collection using the given callback. + * + * @param array|(callable(TValue, TKey): mixed)|string $callback + */ + public function sortBy(callable|array|string $callback, int $options = SORT_REGULAR, bool $descending = false): static + { + if (is_array($callback) && ! is_callable($callback)) { + return $this->sortByMany($callback, $options); + } + + $results = []; + + $callback = $this->valueRetriever($callback); + + // First we will loop through the items and get the comparator from a callback + // function which we were given. Then, we will sort the returned values and + // grab all the corresponding values for the sorted keys from this array. + foreach ($this->items as $key => $value) { + $results[$key] = $callback($value, $key); + } + + $descending ? arsort($results, $options) + : asort($results, $options); + + // Once we have sorted all of the keys in the array, we will loop through them + // and grab the corresponding model so we can set the underlying items list + // to the sorted version. Then we'll just return the collection instance. + foreach (array_keys($results) as $key) { + $results[$key] = $this->items[$key]; + } + + return new static($results); + } + + /** + * Sort the collection using multiple comparisons. + * + * @param array $comparisons + */ + protected function sortByMany(array $comparisons = [], int $options = SORT_REGULAR): static + { + $items = $this->items; + + uasort($items, function ($a, $b) use ($comparisons, $options) { + foreach ($comparisons as $comparison) { + $comparison = Arr::wrap($comparison); + + $prop = $comparison[0]; + + $ascending = Arr::get($comparison, 1, true) === true + || Arr::get($comparison, 1, true) === 'asc'; + + if (! is_string($prop) && is_callable($prop)) { + $result = $prop($a, $b); + } else { + $values = [data_get($a, $prop), data_get($b, $prop)]; + + if (! $ascending) { + $values = array_reverse($values); + } + + if (($options & SORT_FLAG_CASE) === SORT_FLAG_CASE) { + if (($options & SORT_NATURAL) === SORT_NATURAL) { + $result = strnatcasecmp($values[0], $values[1]); + } else { + $result = strcasecmp($values[0], $values[1]); + } + } else { + $result = match ($options) { + SORT_NUMERIC => (int) $values[0] <=> (int) $values[1], + SORT_STRING => strcmp($values[0], $values[1]), + SORT_NATURAL => strnatcmp((string) $values[0], (string) $values[1]), + SORT_LOCALE_STRING => strcoll($values[0], $values[1]), + default => $values[0] <=> $values[1], + }; + } + } + + if ($result === 0) { + continue; + } + + return $result; + } + }); + + return new static($items); + } + + /** + * Sort the collection in descending order using the given callback. + * + * @param array|(callable(TValue, TKey): mixed)|string $callback + */ + public function sortByDesc(callable|array|string $callback, int $options = SORT_REGULAR): static + { + if (is_array($callback) && ! is_callable($callback)) { + foreach ($callback as $index => $key) { + $comparison = Arr::wrap($key); + + $comparison[1] = 'desc'; + + $callback[$index] = $comparison; + } + } + + return $this->sortBy($callback, $options, true); + } + + /** + * Sort the collection keys. + */ + public function sortKeys(int $options = SORT_REGULAR, bool $descending = false): static + { + $items = $this->items; + + $descending ? krsort($items, $options) : ksort($items, $options); + + return new static($items); + } + + /** + * Sort the collection keys in descending order. + */ + public function sortKeysDesc(int $options = SORT_REGULAR): static + { + return $this->sortKeys($options, true); + } + + /** + * Sort the collection keys using a callback. + * + * @param callable(TKey, TKey): int $callback + */ + public function sortKeysUsing(callable $callback): static + { + $items = $this->items; + + uksort($items, $callback); + + return new static($items); + } + + /** + * Splice a portion of the underlying collection array. + * + * @param array $replacement + */ + public function splice(int $offset, ?int $length = null, array $replacement = []): static + { + if (func_num_args() === 1) { + return new static(array_splice($this->items, $offset)); + } + + return new static(array_splice($this->items, $offset, $length, $this->getArrayableItems($replacement))); + } + + /** + * Take the first or last {$limit} items. + */ + public function take(int $limit): static + { + if ($limit < 0) { + return $this->slice($limit, abs($limit)); + } + + return $this->slice(0, $limit); + } + + /** + * Take items in the collection until the given condition is met. + * + * @param callable(TValue,TKey): bool|TValue $value + */ + public function takeUntil(mixed $value): static + { + return new static($this->lazy()->takeUntil($value)->all()); + } + + /** + * Take items in the collection while the given condition is met. + * + * @param callable(TValue,TKey): bool|TValue $value + */ + public function takeWhile(mixed $value): static + { + return new static($this->lazy()->takeWhile($value)->all()); + } + + /** + * Transform each item in the collection using a callback. + * + * @template TMapValue + * + * @param callable(TValue, TKey): TMapValue $callback + * @return $this + * + * @phpstan-this-out static + */ + public function transform(callable $callback): static + { + $this->items = $this->map($callback)->all(); + + return $this; + } + + /** + * Flatten a multi-dimensional associative array with dots. + */ + public function dot(): static + { + return new static(Arr::dot($this->all())); + } + + /** + * Convert a flatten "dot" notation array into an expanded array. + */ + public function undot(): static + { + return new static(Arr::undot($this->all())); + } + + /** + * Return only unique items from the collection array. + * + * @param null|(callable(TValue, TKey): mixed)|string $key + */ + public function unique(callable|string|null $key = null, bool $strict = false): static + { + if (is_null($key) && $strict === false) { + return new static(array_unique($this->items, SORT_REGULAR)); + } + + $callback = $this->valueRetriever($key); + + $exists = []; + + return $this->reject(function ($item, $key) use ($callback, $strict, &$exists) { + if (in_array($id = $callback($item, $key), $exists, $strict)) { + return true; + } + + $exists[] = $id; + }); + } + + /** + * Reset the keys on the underlying array. + * + * @return static + */ + public function values(): static + { + return new static(array_values($this->items)); + } + + /** + * Zip the collection together with one or more arrays. + * + * e.g. new Collection([1, 2, 3])->zip([4, 5, 6]); + * => [[1, 4], [2, 5], [3, 6]] + * + * @template TZipValue + * + * @param Arrayable|iterable ...$items + * @return static> + */ + public function zip(Arrayable|iterable ...$items) + { + $arrayableItems = array_map(fn ($items) => $this->getArrayableItems($items), $items); + + $params = array_merge([fn () => new static(func_get_args()), $this->items], $arrayableItems); + + return new static(array_map(...$params)); + } + + /** + * Pad collection to the specified length with a value. + * + * @template TPadValue + * + * @param TPadValue $value + * @return static + */ + public function pad(int $size, mixed $value) + { + return new static(array_pad($this->items, $size, $value)); + } + + /** + * Get an iterator for the items. + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } + + /** + * Count the number of items in the collection. + * + * @return int<0, max> + */ + public function count(): int + { + return count($this->items); + } + + /** + * Count the number of items in the collection by a field or using a callback. + * + * @param null|(callable(TValue, TKey): (array-key|UnitEnum))|string $countBy + * @return static + */ + public function countBy(callable|string|null $countBy = null) + { + return new static($this->lazy()->countBy($countBy)->all()); + } + + /** + * Add an item to the collection. + * + * @param TValue $item + * @return $this + */ + public function add(mixed $item): static + { + $this->items[] = $item; + + return $this; + } + + /** + * Get a base Support collection instance from this collection. + * + * @return Collection + */ + public function toBase(): Collection + { + return new self($this); + } + + /** + * Determine if an item exists at an offset. + * + * @param TKey $key + */ + public function offsetExists($key): bool + { + return isset($this->items[$key]); + } + + /** + * Get an item at a given offset. + * + * @param TKey $key + * @return TValue + */ + public function offsetGet($key): mixed + { + return $this->items[$key]; + } + + /** + * Set the item at a given offset. + * + * @param null|TKey $key + * @param TValue $value + */ + public function offsetSet($key, $value): void + { + if (is_null($key)) { + $this->items[] = $value; + } else { + $this->items[$key] = $value; + } + } + + /** + * Unset the item at a given offset. + * + * @param TKey $key + */ + public function offsetUnset($key): void + { + unset($this->items[$key]); + } +} diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php new file mode 100644 index 000000000..ad0912d2d --- /dev/null +++ b/src/collections/src/Enumerable.php @@ -0,0 +1,1106 @@ + + * @extends IteratorAggregate + */ +interface Enumerable extends Arrayable, Countable, IteratorAggregate, Jsonable, JsonSerializable +{ + /** + * Create a new collection instance if the value isn't one already. + * + * @template TMakeKey of array-key + * @template TMakeValue + * + * @param null|Arrayable|iterable $items + * @return static + */ + public static function make(Arrayable|iterable|null $items = []): static; + + /** + * Create a new instance by invoking the callback a given amount of times. + */ + public static function times(int $number, ?callable $callback = null): static; + + /** + * Create a collection with the given range. + */ + public static function range(int $from, int $to, int $step = 1): static; + + /** + * Wrap the given value in a collection if applicable. + * + * @template TWrapValue + * + * @param iterable|TWrapValue $value + * @return static + */ + public static function wrap(mixed $value): static; + + /** + * Get the underlying items from the given collection if applicable. + * + * @template TUnwrapKey of array-key + * @template TUnwrapValue + * + * @param array|static $value + * @return array + */ + public static function unwrap(array|Enumerable $value): array; + + /** + * Create a new instance with no items. + */ + public static function empty(): static; + + /** + * Get all items in the enumerable. + */ + public function all(): array; + + /** + * Alias for the "avg" method. + * + * @param null|(callable(TValue): (float|int))|string $callback + */ + public function average(callable|string|null $callback = null): float|int|null; + + /** + * Get the median of a given key. + * + * @param null|array|string $key + */ + public function median(string|array|null $key = null): float|int|null; + + /** + * Get the mode of a given key. + * + * @param null|array|string $key + * @return null|array + */ + public function mode(string|array|null $key = null): ?array; + + /** + * Collapse the items into a single enumerable. + * + * @return static + */ + public function collapse(); + + /** + * Alias for the "contains" method. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + */ + public function some(mixed $key, mixed $operator = null, mixed $value = null): bool; + + /** + * Determine if an item exists, using strict comparison. + * + * @param array-key|(callable(TValue): bool)|TValue $key + * @param null|TValue $value + */ + public function containsStrict(mixed $key, mixed $value = null): bool; + + /** + * Get the average value of a given key. + * + * @param null|(callable(TValue): (float|int))|string $callback + */ + public function avg(callable|string|null $callback = null): float|int|null; + + /** + * Determine if an item exists in the enumerable. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + */ + public function contains(mixed $key, mixed $operator = null, mixed $value = null): bool; + + /** + * Determine if an item is not contained in the collection. + */ + public function doesntContain(mixed $key, mixed $operator = null, mixed $value = null): bool; + + /** + * Cross join with the given lists, returning all possible permutations. + * + * @template TCrossJoinKey of array-key + * @template TCrossJoinValue + * + * @param Arrayable|iterable ...$lists + * @return static> + */ + public function crossJoin(Arrayable|iterable ...$lists): static; + + /** + * Dump the collection and end the script. + */ + public function dd(mixed ...$args): never; + + /** + * Dump the collection. + */ + public function dump(mixed ...$args): static; + + /** + * Get the items that are not present in the given items. + * + * @param Arrayable|iterable $items + */ + public function diff(Arrayable|iterable $items): static; + + /** + * Get the items that are not present in the given items, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + */ + public function diffUsing(Arrayable|iterable $items, callable $callback): static; + + /** + * Get the items whose keys and values are not present in the given items. + * + * @param Arrayable|iterable $items + */ + public function diffAssoc(Arrayable|iterable $items): static; + + /** + * Get the items whose keys and values are not present in the given items, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TKey, TKey): int $callback + */ + public function diffAssocUsing(Arrayable|iterable $items, callable $callback): static; + + /** + * Get the items whose keys are not present in the given items. + * + * @param Arrayable|iterable $items + */ + public function diffKeys(Arrayable|iterable $items): static; + + /** + * Get the items whose keys are not present in the given items, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TKey, TKey): int $callback + */ + public function diffKeysUsing(Arrayable|iterable $items, callable $callback): static; + + /** + * Retrieve duplicate items. + * + * @param null|(callable(TValue): bool)|string $callback + */ + public function duplicates(callable|string|null $callback = null, bool $strict = false): static; + + /** + * Retrieve duplicate items using strict comparison. + * + * @param null|(callable(TValue): bool)|string $callback + */ + public function duplicatesStrict(callable|string|null $callback = null): static; + + /** + * Execute a callback over each item. + * + * @param callable(TValue, TKey): mixed $callback + */ + public function each(callable $callback): static; + + /** + * Execute a callback over each nested chunk of items. + */ + public function eachSpread(callable $callback): static; + + /** + * Determine if all items pass the given truth test. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + */ + public function every(mixed $key, mixed $operator = null, mixed $value = null): bool; + + /** + * Get all items except for those with the specified keys. + * + * @param array|Enumerable $keys + */ + public function except(Enumerable|array $keys): static; + + /** + * Run a filter over each of the items. + * + * @param null|(callable(TValue): bool) $callback + */ + public function filter(?callable $callback = null): static; + + /** + * Apply the callback if the given "value" is (or resolves to) truthy. + * + * @template TWhenReturnType as null + * + * @param null|(callable($this): TWhenReturnType) $callback + * @param null|(callable($this): TWhenReturnType) $default + * @return $this|TWhenReturnType + */ + public function when(mixed $value, ?callable $callback = null, ?callable $default = null): mixed; + + /** + * Apply the callback if the collection is empty. + * + * @template TWhenEmptyReturnType + * + * @param (callable($this): TWhenEmptyReturnType) $callback + * @param null|(callable($this): TWhenEmptyReturnType) $default + * @return $this|TWhenEmptyReturnType + */ + public function whenEmpty(callable $callback, ?callable $default = null): mixed; + + /** + * Apply the callback if the collection is not empty. + * + * @template TWhenNotEmptyReturnType + * + * @param callable($this): TWhenNotEmptyReturnType $callback + * @param null|(callable($this): TWhenNotEmptyReturnType) $default + * @return $this|TWhenNotEmptyReturnType + */ + public function whenNotEmpty(callable $callback, ?callable $default = null): mixed; + + /** + * Apply the callback if the given "value" is (or resolves to) falsy. + * + * @template TUnlessReturnType + * + * @param (callable($this): TUnlessReturnType) $callback + * @param null|(callable($this): TUnlessReturnType) $default + * @return $this|TUnlessReturnType + */ + public function unless(mixed $value, callable $callback, ?callable $default = null): mixed; + + /** + * Apply the callback unless the collection is empty. + * + * @template TUnlessEmptyReturnType + * + * @param callable($this): TUnlessEmptyReturnType $callback + * @param null|(callable($this): TUnlessEmptyReturnType) $default + * @return $this|TUnlessEmptyReturnType + */ + public function unlessEmpty(callable $callback, ?callable $default = null): mixed; + + /** + * Apply the callback unless the collection is not empty. + * + * @template TUnlessNotEmptyReturnType + * + * @param callable($this): TUnlessNotEmptyReturnType $callback + * @param null|(callable($this): TUnlessNotEmptyReturnType) $default + * @return $this|TUnlessNotEmptyReturnType + */ + public function unlessNotEmpty(callable $callback, ?callable $default = null): mixed; + + /** + * Filter items by the given key value pair. + */ + public function where(string $key, mixed $operator = null, mixed $value = null): static; + + /** + * Filter items where the value for the given key is null. + */ + public function whereNull(?string $key = null): static; + + /** + * Filter items where the value for the given key is not null. + */ + public function whereNotNull(?string $key = null): static; + + /** + * Filter items by the given key value pair using strict comparison. + */ + public function whereStrict(string $key, mixed $value): static; + + /** + * Filter items by the given key value pair. + */ + public function whereIn(string $key, Arrayable|iterable $values, bool $strict = false): static; + + /** + * Filter items by the given key value pair using strict comparison. + */ + public function whereInStrict(string $key, Arrayable|iterable $values): static; + + /** + * Filter items such that the value of the given key is between the given values. + */ + public function whereBetween(string $key, Arrayable|iterable $values): static; + + /** + * Filter items such that the value of the given key is not between the given values. + */ + public function whereNotBetween(string $key, Arrayable|iterable $values): static; + + /** + * Filter items by the given key value pair. + */ + public function whereNotIn(string $key, Arrayable|iterable $values, bool $strict = false): static; + + /** + * Filter items by the given key value pair using strict comparison. + */ + public function whereNotInStrict(string $key, Arrayable|iterable $values): static; + + /** + * Filter the items, removing any items that don't match the given type(s). + * + * @template TWhereInstanceOf + * + * @param array>|class-string $type + * @return static + */ + public function whereInstanceOf(string|array $type): static; + + /** + * Get the first item from the enumerable passing the given truth test. + * + * @template TFirstDefault + * + * @param null|(callable(TValue,TKey): bool) $callback + * @param (Closure(): TFirstDefault)|TFirstDefault $default + * @return TFirstDefault|TValue + */ + public function first(?callable $callback = null, mixed $default = null): mixed; + + /** + * Get the first item by the given key value pair. + * + * @return null|TValue + */ + public function firstWhere(string $key, mixed $operator = null, mixed $value = null): mixed; + + /** + * Get a flattened array of the items in the collection. + */ + public function flatten(int|float $depth = INF); + + /** + * Flip the values with their keys. + * + * @return static + * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + */ + public function flip(); + + /** + * Get an item from the collection by key. + * + * @template TGetDefault + * + * @param TKey $key + * @param (Closure(): TGetDefault)|TGetDefault $default + * @return TGetDefault|TValue + */ + public function get(mixed $key, mixed $default = null): mixed; + + /** + * Group an associative array by a field or using a callback. + * + * @template TGroupKey of array-key + * + * @param array|(callable(TValue, TKey): TGroupKey)|string $groupBy + * @return static<($groupBy is string ? array-key : ($groupBy is array ? array-key : TGroupKey)), static<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)>> + */ + public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static; + + /** + * Key an associative array by a field or using a callback. + * + * @template TNewKey of array-key + * + * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy + * @return static<($keyBy is string ? array-key : ($keyBy is array ? array-key : TNewKey)), TValue> + */ + public function keyBy(callable|array|string $keyBy): static; + + /** + * Determine if an item exists in the collection by key. + * + * @param array|TKey $key + */ + public function has(mixed $key): bool; + + /** + * Determine if any of the keys exist in the collection. + */ + public function hasAny(mixed $key): bool; + + /** + * Concatenate values of a given key as a string. + * + * @param (callable(TValue, TKey): mixed)|string $value + */ + public function implode(callable|string $value, ?string $glue = null): string; + + /** + * Intersect the collection with the given items. + * + * @param Arrayable|iterable $items + */ + public function intersect(Arrayable|iterable $items): static; + + /** + * Intersect the collection with the given items, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + */ + public function intersectUsing(Arrayable|iterable $items, callable $callback): static; + + /** + * Intersect the collection with the given items with additional index check. + * + * @param Arrayable|iterable $items + */ + public function intersectAssoc(Arrayable|iterable $items): static; + + /** + * Intersect the collection with the given items with additional index check, using the callback. + * + * @param Arrayable|iterable $items + * @param callable(TValue, TValue): int $callback + */ + public function intersectAssocUsing(Arrayable|iterable $items, callable $callback): static; + + /** + * Intersect the collection with the given items by key. + * + * @param Arrayable|iterable $items + */ + public function intersectByKeys(Arrayable|iterable $items): static; + + /** + * Determine if the collection is empty or not. + */ + public function isEmpty(): bool; + + /** + * Determine if the collection is not empty. + */ + public function isNotEmpty(): bool; + + /** + * Determine if the collection contains a single item. + */ + public function containsOneItem(): bool; + + /** + * Determine if the collection contains multiple items. + */ + public function containsManyItems(): bool; + + /** + * Join all items from the collection using a string. The final items can use a separate glue string. + */ + public function join(string $glue, string $finalGlue = ''): string; + + /** + * Get the keys of the collection items. + * + * @return static + */ + public function keys(); + + /** + * Get the last item from the collection. + * + * @template TLastDefault + * + * @param null|(callable(TValue, TKey): bool) $callback + * @param (Closure(): TLastDefault)|TLastDefault $default + * @return TLastDefault|TValue + */ + public function last(?callable $callback = null, mixed $default = null): mixed; + + /** + * Run a map over each of the items. + * + * @template TMapValue + * + * @param callable(TValue, TKey): TMapValue $callback + * @return static + */ + public function map(callable $callback); + + /** + * Run a map over each nested chunk of items. + */ + 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. + * + * @template TMapToDictionaryKey of array-key + * @template TMapToDictionaryValue + * + * @param callable(TValue, TKey): array $callback + * @return static> + */ + public function mapToDictionary(callable $callback): static; + + /** + * Run a grouping map over the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TMapToGroupsKey of array-key + * @template TMapToGroupsValue + * + * @param callable(TValue, TKey): array $callback + * @return static> + */ + public function mapToGroups(callable $callback): static; + + /** + * Run an associative map over each of the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TMapWithKeysKey of array-key + * @template TMapWithKeysValue + * + * @param callable(TValue, TKey): array $callback + * @return static + */ + public function mapWithKeys(callable $callback); + + /** + * Map a collection and flatten the result by a single level. + * + * @template TFlatMapKey of array-key + * @template TFlatMapValue + * + * @param callable(TValue, TKey): (array|Collection) $callback + * @return static + */ + public function flatMap(callable $callback): static; + + /** + * Map the values into a new class. + * + * @template TMapIntoValue + * + * @param class-string $class + * @return static + */ + public function mapInto(string $class); + + /** + * Merge the collection with the given items. + * + * @template TMergeValue + * + * @param Arrayable|iterable $items + * @return static + */ + public function merge(Arrayable|iterable $items): static; + + /** + * Recursively merge the collection with the given items. + * + * @template TMergeRecursiveValue + * + * @param Arrayable|iterable $items + * @return static + */ + public function mergeRecursive(Arrayable|iterable $items): static; + + /** + * Create a collection by using this collection for keys and another for its values. + * + * @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) + */ + public function combine(Arrayable|iterable $values): static; + + /** + * Union the collection with the given items. + * + * @param Arrayable|iterable $items + */ + public function union(Arrayable|iterable $items): static; + + /** + * Get the min value of a given key. + * + * @param null|(callable(TValue):mixed)|string $callback + */ + public function min(callable|string|null $callback = null): mixed; + + /** + * Get the max value of a given key. + * + * @param null|(callable(TValue):mixed)|string $callback + */ + public function max(callable|string|null $callback = null): mixed; + + /** + * Create a new collection consisting of every n-th element. + */ + public function nth(int $step, int $offset = 0): static; + + /** + * Get the items with the specified keys. + * + * @param array|Enumerable|string $keys + */ + public function only(Enumerable|array|string $keys): static; + + /** + * "Paginate" the collection by slicing it into a smaller collection. + */ + public function forPage(int $page, int $perPage): static; + + /** + * Partition the collection into two arrays using the given callback or key. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + * @return static, static> + */ + public function partition(mixed $key, mixed $operator = null, mixed $value = null); + + /** + * Push all of the given items onto the collection. + * + * @template TConcatKey of array-key + * @template TConcatValue + * + * @param iterable $source + * @return static + */ + public function concat(iterable $source): static; + + /** + * Get one or a specified number of items randomly from the collection. + * + * @return static|TValue + * + * @throws InvalidArgumentException + */ + public function random(?int $number = null): mixed; + + /** + * Reduce the collection to a single value. + * + * @template TReduceInitial + * @template TReduceReturnType + * + * @param callable(TReduceInitial|TReduceReturnType, TValue, TKey): TReduceReturnType $callback + * @param TReduceInitial $initial + * @return TReduceInitial|TReduceReturnType + */ + public function reduce(callable $callback, mixed $initial = null): mixed; + + /** + * Reduce the collection to multiple aggregate values. + * + * @throws UnexpectedValueException + */ + public function reduceSpread(callable $callback, mixed ...$initial): array; + + /** + * Replace the collection items with the given items. + * + * @param Arrayable|iterable $items + */ + public function replace(Arrayable|iterable $items): static; + + /** + * Recursively replace the collection items with the given items. + * + * @param Arrayable|iterable $items + */ + public function replaceRecursive(Arrayable|iterable $items): static; + + /** + * Reverse items order. + */ + public function reverse(): static; + + /** + * Search the collection for a given value and return the corresponding key if successful. + * + * @param (callable(TValue,TKey): bool)|TValue $value + * @return false|TKey + */ + public function search(mixed $value, bool $strict = false): mixed; + + /** + * Get the item before the given item. + * + * @param (callable(TValue,TKey): bool)|TValue $value + * @return null|TValue + */ + public function before(mixed $value, bool $strict = false): mixed; + + /** + * Get the item after the given item. + * + * @param (callable(TValue,TKey): bool)|TValue $value + * @return null|TValue + */ + public function after(mixed $value, bool $strict = false): mixed; + + /** + * Shuffle the items in the collection. + */ + public function shuffle(): static; + + /** + * Create chunks representing a "sliding window" view of the items in the collection. + * + * @return static + */ + public function sliding(int $size = 2, int $step = 1): static; + + /** + * Skip the first {$count} items. + */ + public function skip(int $count): static; + + /** + * Skip items in the collection until the given condition is met. + * + * @param (callable(TValue,TKey): bool)|TValue $value + */ + public function skipUntil(mixed $value): static; + + /** + * Skip items in the collection while the given condition is met. + * + * @param (callable(TValue,TKey): bool)|TValue $value + */ + public function skipWhile(mixed $value): static; + + /** + * Get a slice of items from the enumerable. + */ + public function slice(int $offset, ?int $length = null): static; + + /** + * Split a collection into a certain number of groups. + * + * @return static + */ + public function split(int $numberOfGroups): static; + + /** + * Get the first item in the collection, but only if exactly one item exists. Otherwise, throw an exception. + * + * @param null|(callable(TValue, TKey): bool)|string $key + * @return TValue + * + * @throws ItemNotFoundException + * @throws MultipleItemsFoundException + */ + public function sole(callable|string|null $key = null, mixed $operator = null, mixed $value = null): mixed; + + /** + * Get the first item in the collection but throw an exception if no matching items exist. + * + * @param null|(callable(TValue, TKey): bool)|string $key + * @return TValue + * + * @throws ItemNotFoundException + */ + public function firstOrFail(callable|string|null $key = null, mixed $operator = null, mixed $value = null): mixed; + + /** + * Chunk the collection into chunks of the given size. + * + * @return static + */ + public function chunk(int $size): static; + + /** + * Chunk the collection into chunks with a callback. + * + * @param callable(TValue, TKey, static): bool $callback + * @return static> + */ + public function chunkWhile(callable $callback): static; + + /** + * Split a collection into a certain number of groups, and fill the first groups completely. + * + * @return static + */ + public function splitIn(int $numberOfGroups): static; + + /** + * Sort through each item with a callback. + * + * @param null|(callable(TValue, TValue): int)|int $callback + */ + public function sort(callable|int|null $callback = null): static; + + /** + * Sort items in descending order. + */ + public function sortDesc(int $options = SORT_REGULAR): static; + + /** + * Sort the collection using the given callback. + * + * @param array|(callable(TValue, TKey): mixed)|string $callback + */ + public function sortBy(array|callable|string $callback, int $options = SORT_REGULAR, bool $descending = false): static; + + /** + * Sort the collection in descending order using the given callback. + * + * @param array|(callable(TValue, TKey): mixed)|string $callback + */ + public function sortByDesc(array|callable|string $callback, int $options = SORT_REGULAR): static; + + /** + * Sort the collection keys. + */ + public function sortKeys(int $options = SORT_REGULAR, bool $descending = false): static; + + /** + * Sort the collection keys in descending order. + */ + public function sortKeysDesc(int $options = SORT_REGULAR): static; + + /** + * Sort the collection keys using a callback. + * + * @param callable(TKey, TKey): int $callback + */ + public function sortKeysUsing(callable $callback): static; + + /** + * Get the sum of the given values. + * + * @param null|(callable(TValue): mixed)|string $callback + */ + public function sum(callable|string|null $callback = null): mixed; + + /** + * Take the first or last {$limit} items. + */ + public function take(int $limit): static; + + /** + * Take items in the collection until the given condition is met. + * + * @param (callable(TValue,TKey): bool)|TValue $value + */ + public function takeUntil(mixed $value): static; + + /** + * Take items in the collection while the given condition is met. + * + * @param (callable(TValue,TKey): bool)|TValue $value + */ + public function takeWhile(mixed $value): static; + + /** + * Pass the collection to the given callback and then return it. + * + * @param callable(TValue): mixed $callback + */ + public function tap(callable $callback): static; + + /** + * Pass the enumerable to the given callback and return the result. + * + * @template TPipeReturnType + * + * @param callable($this): TPipeReturnType $callback + * @return TPipeReturnType + */ + public function pipe(callable $callback): mixed; + + /** + * Pass the collection into a new class. + * + * @template TPipeIntoValue + * + * @param class-string $class + * @return TPipeIntoValue + */ + public function pipeInto(string $class): mixed; + + /** + * Pass the collection through a series of callable pipes and return the result. + * + * @param array $pipes + */ + public function pipeThrough(array $pipes): mixed; + + /** + * Get the values of a given key. + * + * @param array|string $value + * @return static + */ + public function pluck(string|array $value, ?string $key = null); + + /** + * Create a collection of all elements that do not pass a given truth test. + * + * @param bool|(callable(TValue, TKey): bool)|TValue $callback + */ + public function reject(mixed $callback = true): static; + + /** + * Convert a flatten "dot" notation array into an expanded array. + */ + public function undot(): static; + + /** + * Return only unique items from the collection array. + * + * @param null|(callable(TValue, TKey): mixed)|string $key + */ + public function unique(callable|string|null $key = null, bool $strict = false): static; + + /** + * Return only unique items from the collection array using strict comparison. + * + * @param null|(callable(TValue, TKey): mixed)|string $key + */ + public function uniqueStrict(callable|string|null $key = null): static; + + /** + * Reset the keys on the underlying array. + * + * @return static + */ + public function values(): static; + + /** + * Pad collection to the specified length with a value. + * + * @template TPadValue + * + * @param TPadValue $value + * @return static + */ + public function pad(int $size, mixed $value); + + /** + * Get the values iterator. + * + * @return Traversable + */ + public function getIterator(): Traversable; + + /** + * Count the number of items in the collection. + */ + public function count(): int; + + /** + * Count the number of items in the collection by a field or using a callback. + * + * @param null|(callable(TValue, TKey): array-key)|string $countBy + * @return static + */ + public function countBy(callable|string|null $countBy = null); + + /** + * Zip the collection together with one or more arrays. + * + * e.g. new Collection([1, 2, 3])->zip([4, 5, 6]); + * => [[1, 4], [2, 5], [3, 6]] + * + * @template TZipValue + * + * @param Arrayable|iterable ...$items + * @return static> + */ + public function zip(Arrayable|iterable ...$items); + + /** + * Collect the values into a collection. + * + * @return Collection + */ + public function collect(): Collection; + + /** + * Get the collection of items as a plain array. + * + * @return array + */ + public function toArray(): array; + + /** + * Convert the object into something JSON serializable. + */ + public function jsonSerialize(): mixed; + + /** + * Get the collection of items as JSON. + */ + public function toJson(int $options = 0): string; + + /** + * Get the collection of items as pretty print formatted JSON. + */ + public function toPrettyJson(int $options = 0): string; + + /** + * Get a CachingIterator instance. + */ + public function getCachingIterator(int $flags = CachingIterator::CALL_TOSTRING): CachingIterator; + + /** + * Convert the collection to its string representation. + */ + public function __toString(): string; + + /** + * Indicate that the model's string representation should be escaped when __toString is invoked. + */ + public function escapeWhenCastingToString(bool $escape = true): static; + + /** + * Add a method to the list of proxied methods. + */ + public static function proxy(string $method): void; + + /** + * Dynamically access collection proxies. + * + * @throws Exception + */ + public function __get(string $key): mixed; +} diff --git a/src/collections/src/Functions.php b/src/collections/src/Functions.php new file mode 100644 index 000000000..896c51af0 --- /dev/null +++ b/src/collections/src/Functions.php @@ -0,0 +1,30 @@ + $value->value, + $value instanceof UnitEnum => $value->name, + + default => $value ?? value($default), + }; +} diff --git a/src/collections/src/HigherOrderCollectionProxy.php b/src/collections/src/HigherOrderCollectionProxy.php new file mode 100644 index 000000000..eb4091101 --- /dev/null +++ b/src/collections/src/HigherOrderCollectionProxy.php @@ -0,0 +1,51 @@ + + * @mixin TValue + */ +class HigherOrderCollectionProxy +{ + /** + * Create a new proxy instance. + * + * @param \Hypervel\Support\Enumerable $collection + */ + public function __construct( + protected Enumerable $collection, + protected string $method + ) { + } + + /** + * Proxy accessing an attribute onto the collection items. + */ + public function __get(string $key): mixed + { + return $this->collection->{$this->method}(function ($value) use ($key) { + return is_array($value) ? $value[$key] : $value->{$key}; + }); + } + + /** + * Proxy a method call onto the collection items. + * + * @param array $parameters + */ + public function __call(string $method, array $parameters): mixed + { + return $this->collection->{$this->method}(function ($value) use ($method, $parameters) { + return is_string($value) + ? $value::{$method}(...$parameters) + : $value->{$method}(...$parameters); + }); + } +} diff --git a/src/collections/src/ItemNotFoundException.php b/src/collections/src/ItemNotFoundException.php new file mode 100644 index 000000000..03d3a1a10 --- /dev/null +++ b/src/collections/src/ItemNotFoundException.php @@ -0,0 +1,11 @@ + + */ +class LazyCollection implements CanBeEscapedWhenCastToString, Enumerable +{ + /** + * @use EnumeratesValues + */ + use EnumeratesValues; + + use Macroable; + + /** + * The source from which to generate items. + * + * @var array|(Closure(): Generator)|static + */ + public Closure|self|array $source; + + /** + * Create a new lazy collection instance. + * + * @param null|array|Arrayable|(Closure(): Generator)|iterable|self $source + */ + public function __construct(mixed $source = null) + { + if ($source instanceof Closure || $source instanceof self) { + $this->source = $source; + } elseif (is_null($source)) { + $this->source = static::empty(); + } elseif ($source instanceof Generator) { + throw new InvalidArgumentException( + 'Generators should not be passed directly to LazyCollection. Instead, pass a generator function.' + ); + } else { + $this->source = $this->getArrayableItems($source); + } + } + + /** + * Create a new collection instance if the value isn't one already. + * + * @template TMakeKey of array-key + * @template TMakeValue + * + * @param null|array|Arrayable|(Closure(): Generator)|iterable|self $items + * @return static + */ + public static function make(mixed $items = []): static + { + return new static($items); + } + + /** + * Create a collection with the given range. + * + * @return static + */ + public static function range(int $from, int $to, int $step = 1): static + { + if ($step == 0) { + throw new InvalidArgumentException('Step value cannot be zero.'); + } + + return new static(function () use ($from, $to, $step) { + if ($from <= $to) { + for (; $from <= $to; $from += abs($step)) { + yield $from; + } + } else { + for (; $from >= $to; $from -= abs($step)) { + yield $from; + } + } + }); + } + + /** + * Get all items in the enumerable. + * + * @return array + */ + public function all(): array + { + if (is_array($this->source)) { + return $this->source; + } + + return iterator_to_array($this->getIterator()); + } + + /** + * Eager load all items into a new lazy collection backed by an array. + * + * @return static + */ + public function eager(): static + { + return new static($this->all()); + } + + /** + * Cache values as they're enumerated. + * + * @return static + */ + public function remember(): static + { + $iterator = $this->getIterator(); + + $iteratorIndex = 0; + + $cache = []; + + return new static(function () use ($iterator, &$iteratorIndex, &$cache) { + for ($index = 0; true; ++$index) { + if (array_key_exists($index, $cache)) { + yield $cache[$index][0] => $cache[$index][1]; + + continue; + } + + if ($iteratorIndex < $index) { + $iterator->next(); + + ++$iteratorIndex; + } + + if (! $iterator->valid()) { + break; + } + + $cache[$index] = [$iterator->key(), $iterator->current()]; + + yield $cache[$index][0] => $cache[$index][1]; + } + }); + } + + /** + * Get the median of a given key. + * + * @param null|array|string $key + */ + public function median(string|array|null $key = null): float|int|null + { + return $this->collect()->median($key); + } + + /** + * Get the mode of a given key. + * + * @param null|array|string $key + * @return null|array + */ + public function mode(string|array|null $key = null): ?array + { + return $this->collect()->mode($key); + } + + /** + * Collapse the collection of items into a single array. + * + * @return static + */ + public function collapse() + { + return new static(function () { + foreach ($this as $values) { + if (is_array($values) || $values instanceof Enumerable) { + foreach ($values as $value) { + yield $value; + } + } + } + }); + } + + /** + * Collapse the collection of items into a single array while preserving its keys. + * + * @return static + */ + public function collapseWithKeys(): static + { + return new static(function () { + foreach ($this as $values) { + if (is_array($values) || $values instanceof Enumerable) { + foreach ($values as $key => $value) { + yield $key => $value; + } + } + } + }); + } + + /** + * Determine if an item exists in the enumerable. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + */ + public function contains(mixed $key, mixed $operator = null, mixed $value = null): bool + { + if (func_num_args() === 1 && $this->useAsCallable($key)) { + $placeholder = new stdClass(); + + /** @var callable $key */ + return $this->first($key, $placeholder) !== $placeholder; + } + + if (func_num_args() === 1) { + $needle = $key; + + foreach ($this as $value) { + if ($value == $needle) { + return true; + } + } + + return false; + } + + return $this->contains($this->operatorForWhere(...func_get_args())); + } + + /** + * Determine if an item exists, using strict comparison. + * + * @param array-key|(callable(TValue): bool)|TValue $key + * @param null|TValue $value + */ + public function containsStrict(mixed $key, mixed $value = null): bool + { + if (func_num_args() === 2) { + return $this->contains(fn ($item) => data_get($item, $key) === $value); + } + + if ($this->useAsCallable($key)) { + return ! is_null($this->first($key)); + } + + foreach ($this as $item) { + if ($item === $key) { + return true; + } + } + + return false; + } + + /** + * Determine if an item is not contained in the enumerable. + */ + public function doesntContain(mixed $key, mixed $operator = null, mixed $value = null): bool + { + return ! $this->contains(...func_get_args()); + } + + /** + * Determine if an item is not contained in the enumerable, using strict comparison. + */ + public function doesntContainStrict(mixed $key, mixed $operator = null, mixed $value = null): bool + { + return ! $this->containsStrict(...func_get_args()); + } + + #[Override] + public function crossJoin(Arrayable|iterable ...$arrays): static + { + // @phpstan-ignore return.type (passthru loses generic type info) + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Count the number of items in the collection by a field or using a callback. + * + * @param null|(callable(TValue, TKey): (array-key|UnitEnum))|string $countBy + * @return static + */ + public function countBy(callable|string|null $countBy = null) + { + $countBy = is_null($countBy) + ? $this->identity() + : $this->valueRetriever($countBy); + + return new static(function () use ($countBy) { + $counts = []; + + foreach ($this as $key => $value) { + $group = enum_value($countBy($value, $key)); + + if (empty($counts[$group])) { + $counts[$group] = 0; + } + + ++$counts[$group]; + } + + yield from $counts; + }); + } + + #[Override] + public function diff(Arrayable|iterable $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function diffUsing(Arrayable|iterable $items, callable $callback): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function diffAssoc(Arrayable|iterable $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function diffAssocUsing(Arrayable|iterable $items, callable $callback): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function diffKeys(Arrayable|iterable $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function diffKeysUsing(Arrayable|iterable $items, callable $callback): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function duplicates(callable|string|null $callback = null, bool $strict = false): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function duplicatesStrict(callable|string|null $callback = null): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function except(Enumerable|array $keys): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Run a filter over each of the items. + * + * @param null|(callable(TValue, TKey): bool) $callback + */ + public function filter(?callable $callback = null): static + { + if (is_null($callback)) { + $callback = fn ($value) => (bool) $value; + } + + return new static(function () use ($callback) { + foreach ($this as $key => $value) { + if ($callback($value, $key)) { + yield $key => $value; + } + } + }); + } + + /** + * Get the first item from the enumerable passing the given truth test. + * + * @template TFirstDefault + * + * @param null|(callable(TValue, TKey): bool) $callback + * @param (Closure(): TFirstDefault)|TFirstDefault $default + * @return TFirstDefault|TValue + */ + public function first(?callable $callback = null, mixed $default = null): mixed + { + $iterator = $this->getIterator(); + + if (is_null($callback)) { + if (! $iterator->valid()) { + return value($default); + } + + return $iterator->current(); + } + + foreach ($iterator as $key => $value) { + if ($callback($value, $key)) { + return $value; + } + } + + return value($default); + } + + /** + * Get a flattened list of the items in the collection. + * + * @return static + */ + public function flatten(int|float $depth = INF) + { + $instance = new static(function () use ($depth) { + foreach ($this as $item) { + if (! is_array($item) && ! $item instanceof Enumerable) { + yield $item; + } elseif ($depth === 1) { + yield from $item; + } else { + yield from (new static($item))->flatten($depth - 1); + } + } + }); + + return $instance->values(); + } + + /** + * Flip the items in the collection. + * + * @return static + * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + */ + public function flip() + { + return new static(function () { + foreach ($this as $key => $value) { + yield $value => $key; + } + }); + } + + /** + * Get an item by key. + * + * @template TGetDefault + * + * @param null|TKey $key + * @param (Closure(): TGetDefault)|TGetDefault $default + * @return TGetDefault|TValue + */ + public function get(mixed $key, mixed $default = null): mixed + { + if (is_null($key)) { + return null; + } + + foreach ($this as $outerKey => $outerValue) { + if ($outerKey == $key) { + return $outerValue; + } + } + + return value($default); + } + + /** + * @template TGroupKey of array-key|\UnitEnum|\Stringable + * + * @param array|(callable(TValue, TKey): TGroupKey)|string $groupBy + * @return static< + * ($groupBy is (array|string) + * ? array-key + * : (TGroupKey is \UnitEnum ? array-key : (TGroupKey is \Stringable ? string : TGroupKey))), + * static<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)> + * > + * @phpstan-ignore method.childReturnType, generics.notSubtype (complex conditional types PHPStan can't match) + */ + #[Override] + public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static + { + // @phpstan-ignore return.type (passthru loses generic type info) + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Key an associative array by a field or using a callback. + * + * @template TNewKey of array-key|\UnitEnum + * + * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is UnitEnum ? array-key : TNewKey)), TValue> + * @phpstan-ignore method.childReturnType (complex conditional return type PHPStan can't verify) + */ + public function keyBy(callable|array|string $keyBy): static + { + return new static(function () use ($keyBy) { + $keyBy = $this->valueRetriever($keyBy); + + foreach ($this as $key => $item) { + $resolvedKey = $keyBy($item, $key); + + if (is_object($resolvedKey)) { + $resolvedKey = (string) $resolvedKey; + } + + yield $resolvedKey => $item; + } + }); + } + + /** + * Determine if an item exists in the collection by key. + */ + public function has(mixed $key): bool + { + $keys = array_flip(is_array($key) ? $key : func_get_args()); + $count = count($keys); + + foreach ($this as $key => $value) { + if (array_key_exists($key, $keys) && --$count == 0) { + return true; + } + } + + return false; + } + + /** + * Determine if any of the keys exist in the collection. + */ + public function hasAny(mixed $key): bool + { + $keys = array_flip(is_array($key) ? $key : func_get_args()); + + foreach ($this as $key => $value) { + if (array_key_exists($key, $keys)) { + return true; + } + } + + return false; + } + + /** + * Concatenate values of a given key as a string. + * + * @param (callable(TValue, TKey): mixed)|string $value + */ + public function implode(callable|string $value, ?string $glue = null): string + { + return $this->collect()->implode(...func_get_args()); + } + + #[Override] + public function intersect(Arrayable|iterable $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function intersectUsing(Arrayable|iterable $items, callable $callback): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function intersectAssoc(Arrayable|iterable $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function intersectAssocUsing(Arrayable|iterable $items, callable $callback): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function intersectByKeys(Arrayable|iterable $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Determine if the items are empty or not. + */ + public function isEmpty(): bool + { + return ! $this->getIterator()->valid(); + } + + /** + * Determine if the collection contains a single item. + */ + public function containsOneItem(): bool + { + return $this->take(2)->count() === 1; + } + + /** + * Determine if the collection contains multiple items. + */ + public function containsManyItems(): bool + { + return $this->take(2)->count() > 1; + } + + /** + * Join all items from the collection using a string. The final items can use a separate glue string. + */ + public function join(string $glue, string $finalGlue = ''): string + { + return $this->collect()->join(...func_get_args()); + } + + /** + * Get the keys of the collection items. + * + * @return static + */ + public function keys() + { + return new static(function () { + foreach ($this as $key => $value) { + yield $key; + } + }); + } + + /** + * Get the last item from the collection. + * + * @template TLastDefault + * + * @param null|(callable(TValue, TKey): bool) $callback + * @param (Closure(): TLastDefault)|TLastDefault $default + * @return TLastDefault|TValue + */ + public function last(?callable $callback = null, mixed $default = null): mixed + { + $needle = $placeholder = new stdClass(); + + foreach ($this as $key => $value) { + if (is_null($callback) || $callback($value, $key)) { + $needle = $value; + } + } + + return $needle === $placeholder ? value($default) : $needle; + } + + /** + * Get the values of a given key. + * + * @param array|string $value + * @return static + */ + public function pluck(string|array $value, ?string $key = null) + { + return new static(function () use ($value, $key) { + [$value, $key] = $this->explodePluckParameters($value, $key); + + foreach ($this as $item) { + $itemValue = data_get($item, $value); + + if (is_null($key)) { + yield $itemValue; + } else { + $itemKey = data_get($item, $key); + + if (is_object($itemKey) && method_exists($itemKey, '__toString')) { + $itemKey = (string) $itemKey; + } + + yield $itemKey => $itemValue; + } + } + }); + } + + /** + * Run a map over each of the items. + * + * @template TMapValue + * + * @param callable(TValue, TKey): TMapValue $callback + * @return static + */ + public function map(callable $callback) + { + return new static(function () use ($callback) { + foreach ($this as $key => $value) { + yield $key => $callback($value, $key); + } + }); + } + + #[Override] + public function mapToDictionary(callable $callback): static + { + // @phpstan-ignore return.type (passthru loses generic type info) + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Run an associative map over each of the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TMapWithKeysKey of array-key + * @template TMapWithKeysValue + * + * @param callable(TValue, TKey): array $callback + * @return static + */ + public function mapWithKeys(callable $callback) + { + return new static(function () use ($callback) { + foreach ($this as $key => $value) { + yield from $callback($value, $key); + } + }); + } + + #[Override] + public function merge(Arrayable|iterable $items): static + { + // @phpstan-ignore return.type (passthru loses generic type info) + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function mergeRecursive(Arrayable|iterable $items): static + { + // @phpstan-ignore return.type (passthru loses generic type info) + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Multiply the items in the collection by the multiplier. + */ + public function multiply(int $multiplier): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Create a collection by using this collection for keys and another for its values. + * + * @template TCombineValue + * + * @param Arrayable|(callable(): Generator)|iterable $values + * @return static + * @phpstan-ignore generics.notSubtype (TValue becomes key - only valid when TValue is array-key, but can't express this constraint) + */ + public function combine(Arrayable|iterable|callable $values): static + { + return new static(function () use ($values) { + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + $values = $this->makeIterator($values); + + $errorMessage = 'Both parameters should have an equal number of elements'; + + foreach ($this as $key) { + if (! $values->valid()) { + trigger_error($errorMessage, E_USER_WARNING); + + break; + } + + yield $key => $values->current(); + + $values->next(); + } + + if ($values->valid()) { + trigger_error($errorMessage, E_USER_WARNING); + } + }); + } + + #[Override] + public function union(Arrayable|iterable $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Create a new collection consisting of every n-th element. + * + * @throws InvalidArgumentException + */ + public function nth(int $step, int $offset = 0): static + { + if ($step < 1) { + throw new InvalidArgumentException('Step value must be at least 1.'); + } + + return new static(function () use ($step, $offset) { + $position = 0; + + foreach ($this->slice($offset) as $item) { + if ($position % $step === 0) { + yield $item; + } + + ++$position; + } + }); + } + + /** + * Get the items with the specified keys. + * + * @param array|Enumerable|string $keys + */ + public function only(Enumerable|array|string $keys): static + { + if ($keys instanceof Enumerable) { + $keys = $keys->all(); + } elseif (! is_array($keys)) { + $keys = func_get_args(); + } + + return new static(function () use ($keys) { + $keys = array_flip($keys); + + foreach ($this as $key => $value) { + if (array_key_exists($key, $keys)) { + yield $key => $value; + + unset($keys[$key]); + + if (empty($keys)) { + break; + } + } + } + }); + } + + /** + * Select specific values from the items within the collection. + * + * @param array|Enumerable|string $keys + */ + public function select(Enumerable|array|string $keys): static + { + if ($keys instanceof Enumerable) { + $keys = $keys->all(); + } elseif (! is_array($keys)) { + $keys = func_get_args(); + } + + return new static(function () use ($keys) { + foreach ($this as $item) { + $result = []; + + foreach ($keys as $key) { + if (Arr::accessible($item) && Arr::exists($item, $key)) { + $result[$key] = $item[$key]; + } elseif (is_object($item) && isset($item->{$key})) { + $result[$key] = $item->{$key}; + } + } + + yield $result; + } + }); + } + + /** + * Push all of the given items onto the collection. + * + * @template TConcatKey of array-key + * @template TConcatValue + * + * @param iterable $source + * @return static + */ + public function concat(iterable $source): static + { + return (new static(function () use ($source) { + yield from $this; + yield from $source; + }))->values(); + } + + /** + * Get one or a specified number of items randomly from the collection. + * + * @return static|TValue + * + * @throws InvalidArgumentException + */ + public function random(?int $number = null): mixed + { + $result = $this->collect()->random(...func_get_args()); + + return is_null($number) ? $result : new static($result); + } + + /** + * Replace the collection items with the given items. + * + * @param Arrayable|iterable $items + */ + public function replace(Arrayable|iterable $items): static + { + return new static(function () use ($items) { + $items = $this->getArrayableItems($items); + + foreach ($this as $key => $value) { + if (array_key_exists($key, $items)) { + yield $key => $items[$key]; + + unset($items[$key]); + } else { + yield $key => $value; + } + } + + foreach ($items as $key => $value) { + yield $key => $value; + } + }); + } + + #[Override] + public function replaceRecursive(Arrayable|iterable $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function reverse(): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Search the collection for a given value and return the corresponding key if successful. + * + * @param (callable(TValue,TKey): bool)|TValue $value + * @return false|TKey + */ + public function search(mixed $value, bool $strict = false): mixed + { + /** @var (callable(TValue,TKey): bool) $predicate */ + $predicate = $this->useAsCallable($value) + ? $value + : function ($item) use ($value, $strict) { + return $strict ? $item === $value : $item == $value; + }; + + foreach ($this as $key => $item) { + if ($predicate($item, $key)) { + return $key; + } + } + + return false; + } + + /** + * Get the item before the given item. + * + * @param (callable(TValue,TKey): bool)|TValue $value + * @return null|TValue + */ + public function before(mixed $value, bool $strict = false): mixed + { + $previous = null; + + /** @var (callable(TValue,TKey): bool) $predicate */ + $predicate = $this->useAsCallable($value) + ? $value + : function ($item) use ($value, $strict) { + return $strict ? $item === $value : $item == $value; + }; + + foreach ($this as $key => $item) { + if ($predicate($item, $key)) { + return $previous; + } + + $previous = $item; + } + + return null; + } + + /** + * Get the item after the given item. + * + * @param (callable(TValue,TKey): bool)|TValue $value + * @return null|TValue + */ + public function after(mixed $value, bool $strict = false): mixed + { + $found = false; + + /** @var (callable(TValue,TKey): bool) $predicate */ + $predicate = $this->useAsCallable($value) + ? $value + : function ($item) use ($value, $strict) { + return $strict ? $item === $value : $item == $value; + }; + + foreach ($this as $key => $item) { + if ($found) { + return $item; + } + + if ($predicate($item, $key)) { + $found = true; + } + } + + return null; + } + + #[Override] + public function shuffle(): static + { + return $this->passthru(__FUNCTION__, []); + } + + /** + * Create chunks representing a "sliding window" view of the items in the collection. + * + * @return static + * + * @throws InvalidArgumentException + */ + public function sliding(int $size = 2, int $step = 1): static + { + if ($size < 1) { + throw new InvalidArgumentException('Size value must be at least 1.'); + } + if ($step < 1) { + throw new InvalidArgumentException('Step value must be at least 1.'); + } + + return new static(function () use ($size, $step) { + $iterator = $this->getIterator(); + + $chunk = []; + + while ($iterator->valid()) { + $chunk[$iterator->key()] = $iterator->current(); + + if (count($chunk) == $size) { + yield (new static($chunk))->tap(function () use (&$chunk, $step) { + $chunk = array_slice($chunk, $step, null, true); + }); + + // If the $step between chunks is bigger than each chunk's $size + // we will skip the extra items (which should never be in any + // chunk) before we continue to the next chunk in the loop. + if ($step > $size) { + $skip = $step - $size; + + for ($i = 0; $i < $skip && $iterator->valid(); ++$i) { + $iterator->next(); + } + } + } + + $iterator->next(); + } + }); + } + + /** + * Skip the first {$count} items. + */ + public function skip(int $count): static + { + return new static(function () use ($count) { + $iterator = $this->getIterator(); + + while ($iterator->valid() && $count--) { + $iterator->next(); + } + + while ($iterator->valid()) { + yield $iterator->key() => $iterator->current(); + + $iterator->next(); + } + }); + } + + /** + * Skip items in the collection until the given condition is met. + * + * @param callable(TValue,TKey): bool|TValue $value + */ + public function skipUntil(mixed $value): static + { + $callback = $this->useAsCallable($value) ? $value : $this->equality($value); + + return $this->skipWhile($this->negate($callback)); + } + + /** + * Skip items in the collection while the given condition is met. + * + * @param callable(TValue,TKey): bool|TValue $value + */ + public function skipWhile(mixed $value): static + { + $callback = $this->useAsCallable($value) ? $value : $this->equality($value); + + return new static(function () use ($callback) { + $iterator = $this->getIterator(); + + while ($iterator->valid() && $callback($iterator->current(), $iterator->key())) { + $iterator->next(); + } + + while ($iterator->valid()) { + yield $iterator->key() => $iterator->current(); + + $iterator->next(); + } + }); + } + + #[Override] + public function slice(int $offset, ?int $length = null): static + { + if ($offset < 0 || $length < 0) { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + $instance = $this->skip($offset); + + return is_null($length) ? $instance : $instance->take($length); + } + + /** + * @throws InvalidArgumentException + */ + #[Override] + public function split(int $numberOfGroups): static + { + if ($numberOfGroups < 1) { + throw new InvalidArgumentException('Number of groups must be at least 1.'); + } + + // @phpstan-ignore return.type (passthru loses generic type info) + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Get the first item in the collection, but only if exactly one item exists. Otherwise, throw an exception. + * + * @param null|(callable(TValue, TKey): bool)|string $key + * @return TValue + * + * @throws ItemNotFoundException + * @throws MultipleItemsFoundException + */ + public function sole(callable|string|null $key = null, mixed $operator = null, mixed $value = null): mixed + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + return $this + ->unless($filter == null) + ->filter($filter) + ->take(2) + ->collect() + ->sole(); + } + + /** + * Get the first item in the collection but throw an exception if no matching items exist. + * + * @param null|(callable(TValue, TKey): bool)|string $key + * @return TValue + * + * @throws ItemNotFoundException + */ + public function firstOrFail(callable|string|null $key = null, mixed $operator = null, mixed $value = null): mixed + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + return $this + ->unless($filter == null) + ->filter($filter) + ->take(1) + ->collect() + ->firstOrFail(); + } + + /** + * Chunk the collection into chunks of the given size. + * + * @return ($preserveKeys is true ? static : static>) + */ + public function chunk(int $size, bool $preserveKeys = true): static + { + if ($size <= 0) { + return static::empty(); + } + + $add = match ($preserveKeys) { + true => fn (array &$chunk, Iterator $iterator) => $chunk[$iterator->key()] = $iterator->current(), + false => fn (array &$chunk, Iterator $iterator) => $chunk[] = $iterator->current(), + }; + + return new static(function () use ($size, $add) { + $iterator = $this->getIterator(); + + while ($iterator->valid()) { + $chunk = []; + + while (true) { + $add($chunk, $iterator); + + if (count($chunk) < $size) { + $iterator->next(); + + if (! $iterator->valid()) { + break; + } + } else { + break; + } + } + + yield new static($chunk); + + $iterator->next(); + } + }); + } + + /** + * Split a collection into a certain number of groups, and fill the first groups completely. + * + * @return static + * + * @throws InvalidArgumentException + */ + public function splitIn(int $numberOfGroups): static + { + if ($numberOfGroups < 1) { + throw new InvalidArgumentException('Number of groups must be at least 1.'); + } + + return $this->chunk((int) ceil($this->count() / $numberOfGroups)); + } + + /** + * Chunk the collection into chunks with a callback. + * + * @param callable(TValue, TKey, static): bool $callback + * @return static> + */ + public function chunkWhile(callable $callback): static + { + return new static(function () use ($callback) { + $iterator = $this->getIterator(); + + $chunk = new Collection(); + + if ($iterator->valid()) { + $chunk[$iterator->key()] = $iterator->current(); + + $iterator->next(); + } + + while ($iterator->valid()) { + // @phpstan-ignore argument.type (callback typed for static but receives Collection chunk) + if (! $callback($iterator->current(), $iterator->key(), $chunk)) { + yield new static($chunk); + + $chunk = new Collection(); + } + + $chunk[$iterator->key()] = $iterator->current(); + + $iterator->next(); + } + + // @phpstan-ignore method.impossibleType (PHPStan infers Collection<*NEVER*, *NEVER*>) + if ($chunk->isNotEmpty()) { + yield new static($chunk); + } + }); + } + + #[Override] + public function sort(callable|int|null $callback = null): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function sortDesc(int $options = SORT_REGULAR): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function sortBy(callable|array|string $callback, int $options = SORT_REGULAR, bool $descending = false): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function sortByDesc(callable|array|string $callback, int $options = SORT_REGULAR): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function sortKeys(int $options = SORT_REGULAR, bool $descending = false): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function sortKeysDesc(int $options = SORT_REGULAR): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function sortKeysUsing(callable $callback): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + /** + * Take the first or last {$limit} items. + * + * @return static + */ + public function take(int $limit): static + { + if ($limit < 0) { + return new static(function () use ($limit) { + $limit = abs($limit); + $ringBuffer = []; + $position = 0; + + foreach ($this as $key => $value) { + $ringBuffer[$position] = [$key, $value]; + $position = ($position + 1) % $limit; + } + + for ($i = 0, $end = min($limit, count($ringBuffer)); $i < $end; ++$i) { + $pointer = ($position + $i) % $limit; + yield $ringBuffer[$pointer][0] => $ringBuffer[$pointer][1]; + } + }); + } + + return new static(function () use ($limit) { + $iterator = $this->getIterator(); + + while ($limit--) { + if (! $iterator->valid()) { + break; + } + + yield $iterator->key() => $iterator->current(); + + if ($limit) { + $iterator->next(); + } + } + }); + } + + /** + * Take items in the collection until the given condition is met. + * + * @param callable(TValue,TKey): bool|TValue $value + * @return static + */ + public function takeUntil(mixed $value): static + { + /** @var callable(TValue, TKey): bool $callback */ + $callback = $this->useAsCallable($value) ? $value : $this->equality($value); + + return new static(function () use ($callback) { + foreach ($this as $key => $item) { + if ($callback($item, $key)) { + break; + } + + yield $key => $item; + } + }); + } + + /** + * Take items in the collection until a given point in time, with an optional callback on timeout. + * + * @param null|callable(null|TValue, null|TKey): mixed $callback + * @return static + */ + public function takeUntilTimeout(DateTimeInterface $timeout, ?callable $callback = null): static + { + $timeout = $timeout->getTimestamp(); + + return new static(function () use ($timeout, $callback) { + if ($this->now() >= $timeout) { + if ($callback) { + $callback(null, null); + } + + return; + } + + foreach ($this as $key => $value) { + yield $key => $value; + + if ($this->now() >= $timeout) { + if ($callback) { + $callback($value, $key); + } + + break; + } + } + }); + } + + /** + * Take items in the collection while the given condition is met. + * + * @param callable(TValue,TKey): bool|TValue $value + * @return static + */ + public function takeWhile(mixed $value): static + { + /** @var callable(TValue, TKey): bool $callback */ + $callback = $this->useAsCallable($value) ? $value : $this->equality($value); + + return $this->takeUntil(fn ($item, $key) => ! $callback($item, $key)); + } + + /** + * Pass each item in the collection to the given callback, lazily. + * + * @param callable(TValue, TKey): mixed $callback + * @return static + */ + public function tapEach(callable $callback): static + { + return new static(function () use ($callback) { + foreach ($this as $key => $value) { + $callback($value, $key); + + yield $key => $value; + } + }); + } + + /** + * Throttle the values, releasing them at most once per the given seconds. + * + * @return static + */ + public function throttle(float $seconds): static + { + return new static(function () use ($seconds) { + $microseconds = $seconds * 1_000_000; + + foreach ($this as $key => $value) { + $fetchedAt = $this->preciseNow(); + + yield $key => $value; + + $sleep = $microseconds - ($this->preciseNow() - $fetchedAt); + + $this->usleep((int) $sleep); + } + }); + } + + /** + * Flatten a multi-dimensional associative array with dots. + */ + public function dot(): static + { + return $this->passthru(__FUNCTION__, []); + } + + #[Override] + public function undot(): static + { + return $this->passthru(__FUNCTION__, []); + } + + /** + * Return only unique items from the collection array. + * + * @param null|(callable(TValue, TKey): mixed)|string $key + * @return static + */ + public function unique(callable|string|null $key = null, bool $strict = false): static + { + $callback = $this->valueRetriever($key); + + return new static(function () use ($callback, $strict) { + $exists = []; + + foreach ($this as $key => $item) { + if (! in_array($id = $callback($item, $key), $exists, $strict)) { + yield $key => $item; + + $exists[] = $id; + } + } + }); + } + + /** + * Reset the keys on the underlying array. + * + * @return static + */ + public function values(): static + { + return new static(function () { + foreach ($this as $item) { + yield $item; + } + }); + } + + /** + * Run the given callback every time the interval has passed. + * + * @return static + */ + public function withHeartbeat(DateInterval|int $interval, callable $callback): static + { + $seconds = is_int($interval) ? $interval : $this->intervalSeconds($interval); + + return new static(function () use ($seconds, $callback) { + $start = $this->now(); + + foreach ($this as $key => $value) { + $now = $this->now(); + + if (($now - $start) >= $seconds) { + $callback(); + + $start = $now; + } + + yield $key => $value; + } + }); + } + + /** + * Get the total seconds from the given interval. + */ + protected function intervalSeconds(DateInterval $interval): int + { + $start = new DateTimeImmutable(); + + return $start->add($interval)->getTimestamp() - $start->getTimestamp(); + } + + /** + * Zip the collection together with one or more arrays. + * + * e.g. new LazyCollection([1, 2, 3])->zip([4, 5, 6]); + * => [[1, 4], [2, 5], [3, 6]] + * + * @template TZipValue + * + * @param Arrayable|iterable ...$items + * @return static> + */ + public function zip(Arrayable|iterable ...$items) + { + $iterables = func_get_args(); + + return new static(function () use ($iterables) { + $iterators = (new Collection($iterables)) + ->map(fn ($iterable) => $this->makeIterator($iterable)) + ->prepend($this->getIterator()); + + while ($iterators->contains->valid()) { + yield new static($iterators->map->current()); + + $iterators->each->next(); + } + }); + } + + #[Override] + public function pad(int $size, mixed $value) + { + if ($size < 0) { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + return new static(function () use ($size, $value) { + $yielded = 0; + + foreach ($this as $index => $item) { + yield $index => $item; + + ++$yielded; + } + + while ($yielded++ < $size) { + yield $value; + } + }); + } + + /** + * Get the values iterator. + * + * @return Iterator + */ + public function getIterator(): Iterator + { + return $this->makeIterator($this->source); + } + + /** + * Count the number of items in the collection. + */ + public function count(): int + { + if (is_array($this->source)) { + return count($this->source); + } + + return iterator_count($this->getIterator()); + } + + /** + * Make an iterator from the given source. + * + * @template TIteratorKey of array-key + * @template TIteratorValue + * + * @param array|(callable(): Generator)|IteratorAggregate $source + * @return Iterator + */ + protected function makeIterator(IteratorAggregate|array|callable $source): Iterator + { + if ($source instanceof IteratorAggregate) { + $iterator = $source->getIterator(); + + return $iterator instanceof Iterator ? $iterator : new IteratorIterator($iterator); + } + + if (is_array($source)) { + return new ArrayIterator($source); + } + + // Only callable remains at this point + $maybeTraversable = $source(); + + // @phpstan-ignore instanceof.alwaysTrue (PHPDoc says Generator but runtime callable could return anything) + if ($maybeTraversable instanceof Iterator) { + return $maybeTraversable; + } + + // @phpstan-ignore deadCode.unreachable (defensive - handles non-Iterator Traversables) + if ($maybeTraversable instanceof Traversable) { + return new IteratorIterator($maybeTraversable); + } + + return new ArrayIterator(Arr::wrap($maybeTraversable)); + } + + /** + * Explode the "value" and "key" arguments passed to "pluck". + * + * @return array{string[], null|string[]} + */ + protected function explodePluckParameters(string|array $value, string|array|Closure|null $key): array + { + $value = is_string($value) ? explode('.', $value) : $value; + + $key = is_null($key) || is_array($key) || $key instanceof Closure ? $key : explode('.', $key); + + return [$value, $key]; + } + + /** + * Pass this lazy collection through a method on the collection class. + * + * @param array $params + */ + protected function passthru(string $method, array $params): static + { + return new static(function () use ($method, $params) { + yield from $this->collect()->{$method}(...$params); + }); + } + + /** + * Get the current time. + */ + protected function now(): int + { + return class_exists(Carbon::class) + ? Carbon::now()->timestamp + : time(); + } + + /** + * Get the precise current time. + */ + protected function preciseNow(): float + { + return class_exists(Carbon::class) + ? Carbon::now()->getPreciseTimestamp() + : microtime(true) * 1_000_000; + } + + /** + * Sleep for the given amount of microseconds. + */ + protected function usleep(int $microseconds): void + { + if ($microseconds <= 0) { + return; + } + + class_exists(Sleep::class) + ? Sleep::usleep($microseconds) + : usleep($microseconds); + } +} diff --git a/src/collections/src/MultipleItemsFoundException.php b/src/collections/src/MultipleItemsFoundException.php new file mode 100644 index 000000000..5876579b2 --- /dev/null +++ b/src/collections/src/MultipleItemsFoundException.php @@ -0,0 +1,30 @@ +count; + } +} diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php new file mode 100644 index 000000000..0825b9127 --- /dev/null +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -0,0 +1,1069 @@ + $average + * @property-read HigherOrderCollectionProxy $avg + * @property-read HigherOrderCollectionProxy $contains + * @property-read HigherOrderCollectionProxy $doesntContain + * @property-read HigherOrderCollectionProxy $each + * @property-read HigherOrderCollectionProxy $every + * @property-read HigherOrderCollectionProxy $filter + * @property-read HigherOrderCollectionProxy $first + * @property-read HigherOrderCollectionProxy $flatMap + * @property-read HigherOrderCollectionProxy $groupBy + * @property-read HigherOrderCollectionProxy $keyBy + * @property-read HigherOrderCollectionProxy $last + * @property-read HigherOrderCollectionProxy $map + * @property-read HigherOrderCollectionProxy $max + * @property-read HigherOrderCollectionProxy $min + * @property-read HigherOrderCollectionProxy $partition + * @property-read HigherOrderCollectionProxy $percentage + * @property-read HigherOrderCollectionProxy $reject + * @property-read HigherOrderCollectionProxy $skipUntil + * @property-read HigherOrderCollectionProxy $skipWhile + * @property-read HigherOrderCollectionProxy $some + * @property-read HigherOrderCollectionProxy $sortBy + * @property-read HigherOrderCollectionProxy $sortByDesc + * @property-read HigherOrderCollectionProxy $sum + * @property-read HigherOrderCollectionProxy $takeUntil + * @property-read HigherOrderCollectionProxy $takeWhile + * @property-read HigherOrderCollectionProxy $unique + * @property-read HigherOrderCollectionProxy $unless + * @property-read HigherOrderCollectionProxy $until + * @property-read HigherOrderCollectionProxy $when + */ +trait EnumeratesValues +{ + use Conditionable; + + /** + * Indicates that the object's string representation should be escaped when __toString is invoked. + */ + protected bool $escapeWhenCastingToString = false; + + /** + * The methods that can be proxied. + * + * @var array + */ + protected static array $proxies = [ + 'average', + 'avg', + 'contains', + 'doesntContain', + 'each', + 'every', + 'filter', + 'first', + 'flatMap', + 'groupBy', + 'keyBy', + 'last', + 'map', + 'max', + 'min', + 'partition', + 'percentage', + 'reject', + 'skipUntil', + 'skipWhile', + 'some', + 'sortBy', + 'sortByDesc', + 'sum', + 'takeUntil', + 'takeWhile', + 'unique', + 'unless', + 'until', + 'when', + ]; + + /** + * Create a new collection instance if the value isn't one already. + * + * @template TMakeKey of array-key + * @template TMakeValue + * + * @param null|Arrayable|iterable $items + * @return static + */ + public static function make(mixed $items = []): static + { + return new static($items); + } + + /** + * Wrap the given value in a collection if applicable. + * + * @template TWrapValue + * + * @param iterable|TWrapValue $value + * @return static + */ + public static function wrap(mixed $value): static + { + return $value instanceof Enumerable + ? new static($value) + : new static(Arr::wrap($value)); + } + + /** + * Get the underlying items from the given collection if applicable. + * + * @template TUnwrapKey of array-key + * @template TUnwrapValue + * + * @param array|static $value + * @return array + */ + public static function unwrap(mixed $value): array + { + return $value instanceof Enumerable ? $value->all() : $value; + } + + /** + * Create a new instance with no items. + */ + public static function empty(): static + { + return new static([]); + } + + /** + * Create a new collection by invoking the callback a given amount of times. + * + * @template TTimesValue + * + * @param null|(callable(int): TTimesValue) $callback + * @return static + */ + public static function times(int $number, ?callable $callback = null): static + { + if ($number < 1) { + return new static(); + } + + return static::range(1, $number) + ->unless($callback == null) + ->map($callback); + } + + /** + * Create a new collection by decoding a JSON string. + * + * @return static + */ + public static function fromJson(string $json, int $depth = 512, int $flags = 0): static + { + return new static(json_decode($json, true, $depth, $flags)); + } + + /** + * Get the average value of a given key. + * + * @param null|(callable(TValue): (float|int))|string $callback + */ + public function avg(callable|string|null $callback = null): float|int|null + { + $callback = $this->valueRetriever($callback); + + $reduced = $this->reduce(static function (&$reduce, $value) use ($callback) { + if (! is_null($resolved = $callback($value))) { + $reduce[0] += $resolved; + ++$reduce[1]; + } + + return $reduce; + }, [0, 0]); + + return $reduced[1] ? $reduced[0] / $reduced[1] : null; + } + + /** + * Alias for the "avg" method. + * + * @param null|(callable(TValue): (float|int))|string $callback + */ + public function average(callable|string|null $callback = null): float|int|null + { + return $this->avg($callback); + } + + /** + * Alias for the "contains" method. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + */ + public function some(mixed $key, mixed $operator = null, mixed $value = null): bool + { + return $this->contains(...func_get_args()); + } + + /** + * Dump the given arguments and terminate execution. + */ + public function dd(mixed ...$args): never + { + dd($this->all(), ...$args); + } + + /** + * Dump the items. + */ + public function dump(mixed ...$args): static + { + dump($this->all(), ...$args); + + return $this; + } + + /** + * Execute a callback over each item. + * + * @param callable(TValue, TKey): mixed $callback + */ + public function each(callable $callback): static + { + foreach ($this as $key => $item) { + if ($callback($item, $key) === false) { + break; + } + } + + return $this; + } + + /** + * Execute a callback over each nested chunk of items. + * + * @param callable(mixed...): mixed $callback + */ + public function eachSpread(callable $callback): static + { + return $this->each(function ($chunk, $key) use ($callback) { + $chunk[] = $key; + + return $callback(...$chunk); + }); + } + + /** + * Determine if all items pass the given truth test. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + */ + public function every(mixed $key, mixed $operator = null, mixed $value = null): bool + { + if (func_num_args() === 1) { + $callback = $this->valueRetriever($key); + + foreach ($this as $k => $v) { + if (! $callback($v, $k)) { + return false; + } + } + + return true; + } + + return $this->every($this->operatorForWhere(...func_get_args())); + } + + /** + * Get the first item by the given key value pair. + * + * @return null|TValue + */ + public function firstWhere(string $key, mixed $operator = null, mixed $value = null): mixed + { + return $this->first($this->operatorForWhere(...func_get_args())); + } + + /** + * Get a single key's value from the first matching item in the collection. + * + * @template TValueDefault + * + * @param (Closure(): TValueDefault)|TValueDefault $default + * @return TValue|TValueDefault + */ + public function value(string $key, mixed $default = null): mixed + { + $value = $this->first(function ($target) use ($key) { + return data_has($target, $key); + }); + + return data_get($value, $key, $default); + } + + /** + * Ensure that every item in the collection is of the expected type. + * + * @template TEnsureOfType + * + * @param 'array'|'bool'|'float'|'int'|'null'|'string'|array>|class-string $type + * @return static + * + * @throws UnexpectedValueException + */ + public function ensure(string|array $type): static + { + $allowedTypes = is_array($type) ? $type : [$type]; + + // @phpstan-ignore return.type (type narrowing: throws if items don't match, but PHPStan can't track this) + return $this->each(function ($item, $index) use ($allowedTypes) { + $itemType = get_debug_type($item); + + foreach ($allowedTypes as $allowedType) { + if ($itemType === $allowedType || $item instanceof $allowedType) { + return true; + } + } + + throw new UnexpectedValueException( + sprintf("Collection should only include [%s] items, but '%s' found at position %d.", implode(', ', $allowedTypes), $itemType, $index) + ); + }); + } + + /** + * Determine if the collection is not empty. + * + * @phpstan-assert-if-true TValue $this->first() + * @phpstan-assert-if-true TValue $this->last() + * + * @phpstan-assert-if-false null $this->first() + * @phpstan-assert-if-false null $this->last() + */ + public function isNotEmpty(): bool + { + return ! $this->isEmpty(); + } + + /** + * Run a map over each nested chunk of items. + * + * @template TMapSpreadValue + * + * @param callable(mixed...): TMapSpreadValue $callback + * @return static + */ + public function mapSpread(callable $callback): static + { + return $this->map(function ($chunk, $key) use ($callback) { + $chunk[] = $key; + + return $callback(...$chunk); + }); + } + + /** + * Run a grouping map over the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TMapToGroupsKey of array-key + * @template TMapToGroupsValue + * + * @param callable(TValue, TKey): array $callback + * @return static> + */ + public function mapToGroups(callable $callback): static + { + $groups = $this->mapToDictionary($callback); + + return $groups->map($this->make(...)); + } + + /** + * Map a collection and flatten the result by a single level. + * + * @template TFlatMapKey of array-key + * @template TFlatMapValue + * + * @param callable(TValue, TKey): (array|Collection) $callback + * @return static + */ + public function flatMap(callable $callback): static + { + return $this->map($callback)->collapse(); + } + + /** + * Map the values into a new class. + * + * @template TMapIntoValue + * + * @param class-string $class + * @return static + */ + public function mapInto(string $class) + { + if (is_subclass_of($class, BackedEnum::class)) { + return $this->map(fn ($value, $key) => $class::from($value)); + } + + return $this->map(fn ($value, $key) => new $class($value, $key)); + } + + /** + * Get the min value of a given key. + * + * @param null|(callable(TValue):mixed)|string $callback + */ + public function min(callable|string|null $callback = null): mixed + { + $callback = $this->valueRetriever($callback); + + return $this->map(fn ($value) => $callback($value)) + ->reject(fn ($value) => is_null($value)) + ->reduce(fn ($result, $value) => is_null($result) || $value < $result ? $value : $result); + } + + /** + * Get the max value of a given key. + * + * @param null|(callable(TValue):mixed)|string $callback + */ + public function max(callable|string|null $callback = null): mixed + { + $callback = $this->valueRetriever($callback); + + return $this->reject(fn ($value) => is_null($value))->reduce(function ($result, $item) use ($callback) { + $value = $callback($item); + + return is_null($result) || $value > $result ? $value : $result; + }); + } + + /** + * "Paginate" the collection by slicing it into a smaller collection. + */ + public function forPage(int $page, int $perPage): static + { + $offset = max(0, ($page - 1) * $perPage); + + return $this->slice($offset, $perPage); + } + + /** + * Partition the collection into two arrays using the given callback or key. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + * @return static, static> + */ + public function partition(mixed $key, mixed $operator = null, mixed $value = null) + { + $callback = func_num_args() === 1 + ? $this->valueRetriever($key) + : $this->operatorForWhere(...func_get_args()); + + [$passed, $failed] = Arr::partition($this->getIterator(), $callback); + + // @phpstan-ignore return.type (returns exactly 2 elements with keys 0,1 but PHPStan infers int) + return new static([new static($passed), new static($failed)]); + } + + /** + * Calculate the percentage of items that pass a given truth test. + * + * @param (callable(TValue, TKey): bool) $callback + */ + public function percentage(callable $callback, int $precision = 2): ?float + { + if ($this->isEmpty()) { + return null; + } + + return round( + $this->filter($callback)->count() / $this->count() * 100, + $precision + ); + } + + /** + * Get the sum of the given values. + * + * @template TReturnType + * + * @param null|(callable(TValue): TReturnType)|string $callback + * @return ($callback is callable ? TReturnType : mixed) + */ + public function sum(callable|string|null $callback = null): mixed + { + $callback = is_null($callback) + ? $this->identity() + : $this->valueRetriever($callback); + + return $this->reduce(fn ($result, $item) => $result + $callback($item), 0); + } + + /** + * Apply the callback if the collection is empty. + * + * @template TWhenEmptyReturnType + * + * @param (callable($this): TWhenEmptyReturnType) $callback + * @param null|(callable($this): TWhenEmptyReturnType) $default + * @return $this|TWhenEmptyReturnType + */ + public function whenEmpty(callable $callback, ?callable $default = null): mixed + { + return $this->when($this->isEmpty(), $callback, $default); + } + + /** + * Apply the callback if the collection is not empty. + * + * @template TWhenNotEmptyReturnType + * + * @param callable($this): TWhenNotEmptyReturnType $callback + * @param null|(callable($this): TWhenNotEmptyReturnType) $default + * @return $this|TWhenNotEmptyReturnType + */ + public function whenNotEmpty(callable $callback, ?callable $default = null): mixed + { + return $this->when($this->isNotEmpty(), $callback, $default); + } + + /** + * Apply the callback unless the collection is empty. + * + * @template TUnlessEmptyReturnType + * + * @param callable($this): TUnlessEmptyReturnType $callback + * @param null|(callable($this): TUnlessEmptyReturnType) $default + * @return $this|TUnlessEmptyReturnType + */ + public function unlessEmpty(callable $callback, ?callable $default = null): mixed + { + return $this->whenNotEmpty($callback, $default); + } + + /** + * Apply the callback unless the collection is not empty. + * + * @template TUnlessNotEmptyReturnType + * + * @param callable($this): TUnlessNotEmptyReturnType $callback + * @param null|(callable($this): TUnlessNotEmptyReturnType) $default + * @return $this|TUnlessNotEmptyReturnType + */ + public function unlessNotEmpty(callable $callback, ?callable $default = null): mixed + { + return $this->whenEmpty($callback, $default); + } + + /** + * Filter items by the given key value pair. + */ + public function where(callable|string $key, mixed $operator = null, mixed $value = null): static + { + return $this->filter($this->operatorForWhere(...func_get_args())); + } + + /** + * Filter items where the value for the given key is null. + */ + public function whereNull(?string $key = null): static + { + return $this->whereStrict($key, null); + } + + /** + * Filter items where the value for the given key is not null. + */ + public function whereNotNull(?string $key = null): static + { + return $this->where($key, '!==', null); + } + + /** + * Filter items by the given key value pair using strict comparison. + */ + public function whereStrict(string $key, mixed $value): static + { + return $this->where($key, '===', $value); + } + + /** + * Filter items by the given key value pair. + */ + public function whereIn(string $key, Arrayable|iterable $values, bool $strict = false): static + { + $values = $this->getArrayableItems($values); + + return $this->filter(fn ($item) => in_array(data_get($item, $key), $values, $strict)); + } + + /** + * Filter items by the given key value pair using strict comparison. + */ + public function whereInStrict(string $key, Arrayable|iterable $values): static + { + return $this->whereIn($key, $values, true); + } + + /** + * Filter items such that the value of the given key is between the given values. + */ + public function whereBetween(string $key, Arrayable|iterable $values): static + { + return $this->where($key, '>=', reset($values))->where($key, '<=', end($values)); + } + + /** + * Filter items such that the value of the given key is not between the given values. + */ + public function whereNotBetween(string $key, Arrayable|iterable $values): static + { + return $this->filter( + fn ($item) => data_get($item, $key) < reset($values) || data_get($item, $key) > end($values) + ); + } + + /** + * Filter items by the given key value pair. + */ + public function whereNotIn(string $key, Arrayable|iterable $values, bool $strict = false): static + { + $values = $this->getArrayableItems($values); + + return $this->reject(fn ($item) => in_array(data_get($item, $key), $values, $strict)); + } + + /** + * Filter items by the given key value pair using strict comparison. + */ + public function whereNotInStrict(string $key, Arrayable|iterable $values): static + { + return $this->whereNotIn($key, $values, true); + } + + /** + * Filter the items, removing any items that don't match the given type(s). + * + * @template TWhereInstanceOf + * + * @param array>|class-string $type + * @return static + */ + public function whereInstanceOf(string|array $type): static + { + // @phpstan-ignore return.type (type narrowing: filter only keeps matching instances, but PHPStan can't track this) + return $this->filter(function ($value) use ($type) { + if (is_array($type)) { + foreach ($type as $classType) { + if ($value instanceof $classType) { + return true; + } + } + + return false; + } + + return $value instanceof $type; + }); + } + + /** + * Pass the collection to the given callback and return the result. + * + * @template TPipeReturnType + * + * @param callable($this): TPipeReturnType $callback + * @return TPipeReturnType + */ + public function pipe(callable $callback): mixed + { + return $callback($this); + } + + /** + * Pass the collection into a new class. + * + * @template TPipeIntoValue + * + * @param class-string $class + * @return TPipeIntoValue + */ + public function pipeInto(string $class): mixed + { + return new $class($this); + } + + /** + * Pass the collection through a series of callable pipes and return the result. + * + * @param array $callbacks + */ + public function pipeThrough(array $callbacks): mixed + { + return (new Collection($callbacks))->reduce( + fn ($carry, $callback) => $callback($carry), + $this, + ); + } + + /** + * Reduce the collection to a single value. + * + * @template TReduceInitial + * @template TReduceReturnType + * + * @param callable(TReduceInitial|TReduceReturnType, TValue, TKey): TReduceReturnType $callback + * @param TReduceInitial $initial + * @return TReduceReturnType + */ + public function reduce(callable $callback, mixed $initial = null): mixed + { + $result = $initial; + + foreach ($this as $key => $value) { + $result = $callback($result, $value, $key); + } + + return $result; + } + + /** + * Reduce the collection to multiple aggregate values. + * + * @throws UnexpectedValueException + */ + public function reduceSpread(callable $callback, mixed ...$initial): array + { + $result = $initial; + + foreach ($this as $key => $value) { + $result = call_user_func_array($callback, array_merge($result, [$value, $key])); + + if (! is_array($result)) { + throw new UnexpectedValueException(sprintf( + "%s::reduceSpread expects reducer to return an array, but got a '%s' instead.", + class_basename(static::class), + gettype($result) + )); + } + } + + return $result; + } + + /** + * Reduce an associative collection to a single value. + * + * @template TReduceWithKeysInitial + * @template TReduceWithKeysReturnType + * + * @param callable(TReduceWithKeysInitial|TReduceWithKeysReturnType, TValue, TKey): TReduceWithKeysReturnType $callback + * @param TReduceWithKeysInitial $initial + * @return TReduceWithKeysReturnType + */ + public function reduceWithKeys(callable $callback, mixed $initial = null): mixed + { + return $this->reduce($callback, $initial); + } + + /** + * Create a collection of all elements that do not pass a given truth test. + * + * @param bool|(callable(TValue, TKey): bool)|TValue $callback + */ + public function reject(mixed $callback = true): static + { + $useAsCallable = $this->useAsCallable($callback); + + return $this->filter(function ($value, $key) use ($callback, $useAsCallable) { + return $useAsCallable + ? ! $callback($value, $key) + : $value != $callback; + }); + } + + /** + * Pass the collection to the given callback and then return it. + * + * @param callable($this): mixed $callback + */ + public function tap(callable $callback): static + { + $callback($this); + + return $this; + } + + /** + * Return only unique items from the collection array. + * + * @param null|(callable(TValue, TKey): mixed)|string $key + */ + public function unique(callable|string|null $key = null, bool $strict = false): static + { + $callback = $this->valueRetriever($key); + + $exists = []; + + return $this->reject(function ($item, $key) use ($callback, $strict, &$exists) { + if (in_array($id = $callback($item, $key), $exists, $strict)) { + return true; + } + + $exists[] = $id; + }); + } + + /** + * Return only unique items from the collection array using strict comparison. + * + * @param null|(callable(TValue, TKey): mixed)|string $key + */ + public function uniqueStrict(callable|string|null $key = null): static + { + return $this->unique($key, true); + } + + /** + * Collect the values into a collection. + * + * @return Collection + */ + public function collect(): Collection + { + return new Collection($this->all()); + } + + /** + * Get the collection of items as a plain array. + * + * @return array + */ + public function toArray(): array + { + return $this->map(fn ($value) => $value instanceof Arrayable ? $value->toArray() : $value)->all(); + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + public function jsonSerialize(): array + { + return array_map(function ($value) { + return match (true) { + $value instanceof JsonSerializable => $value->jsonSerialize(), + $value instanceof Jsonable => json_decode($value->toJson(), true), + $value instanceof Arrayable => $value->toArray(), + default => $value, + }; + }, $this->all()); + } + + /** + * Get the collection of items as JSON. + */ + public function toJson(int $options = 0): string + { + return json_encode($this->jsonSerialize(), $options); + } + + /** + * Get the collection of items as pretty print formatted JSON. + */ + public function toPrettyJson(int $options = 0): string + { + return $this->toJson(JSON_PRETTY_PRINT | $options); + } + + /** + * Get a CachingIterator instance. + */ + public function getCachingIterator(int $flags = CachingIterator::CALL_TOSTRING): CachingIterator + { + // @phpstan-ignore argument.type (PHP accepts any int for flags and masks it) + return new CachingIterator($this->getIterator(), $flags); + } + + /** + * Convert the collection to its string representation. + */ + public function __toString(): string + { + return $this->escapeWhenCastingToString + ? e($this->toJson()) + : $this->toJson(); + } + + /** + * Indicate that the model's string representation should be escaped when __toString is invoked. + */ + public function escapeWhenCastingToString(bool $escape = true): static + { + $this->escapeWhenCastingToString = $escape; + + return $this; + } + + /** + * Add a method to the list of proxied methods. + */ + public static function proxy(string $method): void + { + static::$proxies[] = $method; + } + + /** + * Dynamically access collection proxies. + * + * @throws Exception + */ + public function __get(string $key): mixed + { + if (! in_array($key, static::$proxies)) { + throw new Exception("Property [{$key}] does not exist on this collection instance."); + } + + return new HigherOrderCollectionProxy($this, $key); + } + + /** + * Results array of items from Collection or Arrayable. + * + * @return array + */ + protected function getArrayableItems(mixed $items): array + { + return is_null($items) || is_scalar($items) || $items instanceof UnitEnum + ? Arr::wrap($items) + : Arr::from($items); + } + + /** + * Get an operator checker callback. + */ + protected function operatorForWhere(callable|string $key, mixed $operator = null, mixed $value = null): callable + { + if ($this->useAsCallable($key)) { + return $key; + } + + if (func_num_args() === 1) { + $value = true; + + $operator = '='; + } + + if (func_num_args() === 2) { + $value = $operator; + + $operator = '='; + } + + return function ($item) use ($key, $operator, $value) { + $retrieved = enum_value(data_get($item, $key)); + $value = enum_value($value); + + $strings = array_filter([$retrieved, $value], function ($value) { + return match (true) { + is_string($value) => true, + $value instanceof Stringable => true, + default => false, + }; + }); + + if (count($strings) < 2 && count(array_filter([$retrieved, $value], 'is_object')) == 1) { + return in_array($operator, ['!=', '<>', '!==']); + } + + switch ($operator) { + default: + case '=': + case '==': return $retrieved == $value; + case '!=': + case '<>': return $retrieved != $value; + case '<': return $retrieved < $value; + case '>': return $retrieved > $value; + case '<=': return $retrieved <= $value; + case '>=': return $retrieved >= $value; + case '===': return $retrieved === $value; + case '!==': return $retrieved !== $value; + case '<=>': return $retrieved <=> $value; + } + }; + } + + /** + * Determine if the given value is callable, but not a string. + */ + protected function useAsCallable(mixed $value): bool + { + return ! is_string($value) && is_callable($value); + } + + /** + * Get a value retrieving callback. + */ + protected function valueRetriever(callable|string|null $value): callable + { + if ($this->useAsCallable($value)) { + return $value; + } + + return fn ($item) => data_get($item, $value); + } + + /** + * Make a function to check an item's equality. + * + * @return Closure(mixed): bool + */ + protected function equality(mixed $value): Closure + { + return fn ($item) => $item === $value; + } + + /** + * Make a function using another function, by negating its result. + */ + protected function negate(Closure $callback): Closure + { + return fn (...$params) => ! $callback(...$params); + } + + /** + * Make a function that returns what's passed to it. + * + * @return Closure(TValue): TValue + */ + protected function identity(): Closure + { + return fn ($value) => $value; + } +} diff --git a/src/support/src/Traits/TransformsToResourceCollection.php b/src/collections/src/Traits/TransformsToResourceCollection.php similarity index 80% rename from src/support/src/Traits/TransformsToResourceCollection.php rename to src/collections/src/Traits/TransformsToResourceCollection.php index b957c21f4..7bbce5ea5 100644 --- a/src/support/src/Traits/TransformsToResourceCollection.php +++ b/src/collections/src/Traits/TransformsToResourceCollection.php @@ -6,20 +6,20 @@ use Hypervel\Database\Eloquent\Attributes\UseResource; use Hypervel\Database\Eloquent\Attributes\UseResourceCollection; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Http\Resources\Json\JsonResource; use Hypervel\Http\Resources\Json\ResourceCollection; use LogicException; use ReflectionClass; use Throwable; -/** - * Provides the ability to transform a collection to a resource collection. - */ trait TransformsToResourceCollection { /** * Create a new resource collection instance for the given resource. * * @param null|class-string<\Hypervel\Http\Resources\Json\JsonResource> $resourceClass + * * @throws Throwable */ public function toResourceCollection(?string $resourceClass = null): ResourceCollection @@ -46,14 +46,11 @@ protected function guessResourceCollection(): ResourceCollection throw_unless(is_object($model), LogicException::class, 'Resource collection guesser expects the collection to contain objects.'); - /** @var class-string $className */ + /** @var class-string $className */ $className = get_class($model); - throw_unless( - method_exists($className, 'guessResourceName'), - LogicException::class, - sprintf('Expected class %s to implement guessResourceName method. Make sure the model uses the TransformsToResource trait.', $className) - ); + // @phpstan-ignore function.alreadyNarrowedType (defensive: validates model uses TransformsToResource trait) + throw_unless(method_exists($className, 'guessResourceName'), LogicException::class, sprintf('Expected class %s to implement guessResourceName method. Make sure the model uses the TransformsToResource trait.', $className)); $useResourceCollection = $this->resolveResourceCollectionFromAttribute($className); @@ -71,13 +68,14 @@ protected function guessResourceCollection(): ResourceCollection foreach ($resourceClasses as $resourceClass) { $resourceCollection = $resourceClass . 'Collection'; + if (class_exists($resourceCollection)) { return new $resourceCollection($this); } } foreach ($resourceClasses as $resourceClass) { - if (is_string($resourceClass) && class_exists($resourceClass)) { + if (class_exists($resourceClass)) { return $resourceClass::collection($this); } } @@ -86,10 +84,10 @@ protected function guessResourceCollection(): ResourceCollection } /** - * Get the resource class from the UseResource attribute. + * Get the resource class from the class attribute. * * @param class-string $class - * @return null|class-string<\Hypervel\Http\Resources\Json\JsonResource> + * @return null|class-string */ protected function resolveResourceFromAttribute(string $class): ?string { @@ -105,10 +103,10 @@ protected function resolveResourceFromAttribute(string $class): ?string } /** - * Get the resource collection class from the UseResourceCollection attribute. + * Get the resource collection class from the class attribute. * * @param class-string $class - * @return null|class-string<\Hypervel\Http\Resources\Json\ResourceCollection> + * @return null|class-string */ protected function resolveResourceCollectionFromAttribute(string $class): ?string { diff --git a/src/collections/src/helpers.php b/src/collections/src/helpers.php new file mode 100644 index 000000000..1112e45cc --- /dev/null +++ b/src/collections/src/helpers.php @@ -0,0 +1,290 @@ +|iterable $value + * @return \Hypervel\Support\Collection + */ + function collect($value = []): Collection + { + return new Collection($value); + } +} + +if (! function_exists('data_fill')) { + /** + * Fill in data where it's missing. + * + * @param mixed $target + * @param array|string $key + * @param mixed $value + * @return mixed + */ + function data_fill(&$target, $key, $value) + { + return data_set($target, $key, $value, false); + } +} + +if (! function_exists('data_has')) { + /** + * Determine if a key / property exists on an array or object using "dot" notation. + * + * @param mixed $target + * @param null|array|int|string $key + */ + function data_has($target, $key): bool + { + if (is_null($key) || $key === []) { + return false; + } + + $key = is_array($key) ? $key : explode('.', $key); + + foreach ($key as $segment) { + if (Arr::accessible($target) && Arr::exists($target, $segment)) { + $target = $target[$segment]; + } elseif (is_object($target) && property_exists($target, $segment)) { + $target = $target->{$segment}; + } else { + return false; + } + } + + return true; + } +} + +if (! function_exists('data_get')) { + /** + * Get an item from an array or object using "dot" notation. + * + * @param mixed $target + * @param null|array|int|string $key + * @param mixed $default + * @return mixed + */ + function data_get($target, $key, $default = null) + { + if (is_null($key)) { + return $target; + } + + $key = is_array($key) ? $key : explode('.', $key); + + foreach ($key as $i => $segment) { + unset($key[$i]); + + if (is_null($segment)) { + return $target; + } + + if ($segment === '*') { + if ($target instanceof Collection) { + $target = $target->all(); + } elseif (! is_iterable($target)) { + return value($default); + } + + $result = []; + + foreach ($target as $item) { + $result[] = data_get($item, $key); + } + + return in_array('*', $key) ? Arr::collapse($result) : $result; + } + + $segment = match ($segment) { + '\*' => '*', + '\{first}' => '{first}', + '{first}' => array_key_first(Arr::from($target)), + '\{last}' => '{last}', + '{last}' => array_key_last(Arr::from($target)), + default => $segment, + }; + + if (Arr::accessible($target) && Arr::exists($target, $segment)) { + $target = $target[$segment]; + } elseif (is_object($target) && isset($target->{$segment})) { + $target = $target->{$segment}; + } else { + return value($default); + } + } + + return $target; + } +} + +if (! function_exists('data_set')) { + /** + * Set an item on an array or object using dot notation. + * + * @param mixed $target + * @param array|string $key + * @param mixed $value + * @param bool $overwrite + * @return mixed + */ + function data_set(&$target, $key, $value, $overwrite = true) + { + $segments = is_array($key) ? $key : explode('.', $key); + + if (($segment = array_shift($segments)) === '*') { + if (! Arr::accessible($target)) { + $target = []; + } + + if ($segments) { + foreach ($target as &$inner) { + data_set($inner, $segments, $value, $overwrite); + } + } elseif ($overwrite) { + foreach ($target as &$inner) { + $inner = $value; + } + } + } elseif (Arr::accessible($target)) { + if ($segments) { + if (! Arr::exists($target, $segment)) { + $target[$segment] = []; + } + + data_set($target[$segment], $segments, $value, $overwrite); + } elseif ($overwrite || ! Arr::exists($target, $segment)) { + $target[$segment] = $value; + } + } elseif (is_object($target)) { + if ($segments) { + if (! isset($target->{$segment})) { + $target->{$segment} = []; + } + + data_set($target->{$segment}, $segments, $value, $overwrite); + } elseif ($overwrite || ! isset($target->{$segment})) { + $target->{$segment} = $value; + } + } else { + $target = []; + + if ($segments) { + data_set($target[$segment], $segments, $value, $overwrite); + } elseif ($overwrite) { + $target[$segment] = $value; + } + } + + return $target; + } +} + +if (! function_exists('data_forget')) { + /** + * Remove / unset an item from an array or object using "dot" notation. + * + * @param mixed $target + * @param null|array|int|string $key + * @return mixed + */ + function data_forget(&$target, $key) + { + $segments = is_array($key) ? $key : explode('.', $key); + + if (($segment = array_shift($segments)) === '*' && Arr::accessible($target)) { + if ($segments) { + foreach ($target as &$inner) { + data_forget($inner, $segments); + } + } + } elseif (Arr::accessible($target)) { + if ($segments && Arr::exists($target, $segment)) { + data_forget($target[$segment], $segments); + } else { + Arr::forget($target, $segment); + } + } elseif (is_object($target)) { + if ($segments && isset($target->{$segment})) { + data_forget($target->{$segment}, $segments); + } elseif (isset($target->{$segment})) { + unset($target->{$segment}); + } + } + + return $target; + } +} + +if (! function_exists('head')) { + /** + * Get the first element of an array. Useful for method chaining. + * + * @param array $array + * @return mixed + */ + function head($array) + { + return empty($array) ? false : array_first($array); + } +} + +if (! function_exists('last')) { + /** + * Get the last element from an array. + * + * @param array $array + * @return mixed + */ + function last($array) + { + return empty($array) ? false : array_last($array); + } +} + +if (! function_exists('value')) { + /** + * Return the default value of the given value. + * + * @template TValue + * @template TArgs + * + * @param \Closure(TArgs): TValue|TValue $value + * @param TArgs ...$args + * @return TValue + */ + function value($value, ...$args) + { + return $value instanceof Closure ? $value(...$args) : $value; + } +} + +if (! function_exists('when')) { + /** + * Return a value if the given condition is true. + * + * @param mixed $condition + * @param \Closure|mixed $value + * @param \Closure|mixed $default + * @return mixed + */ + function when($condition, $value, $default = null) + { + $condition = $condition instanceof Closure ? $condition() : $condition; + + if ($condition) { + return value($value, $condition); + } + + return value($default, $condition); + } +} diff --git a/src/conditionable/LICENSE.md b/src/conditionable/LICENSE.md new file mode 100644 index 000000000..1fdd1ef99 --- /dev/null +++ b/src/conditionable/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +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/conditionable/composer.json b/src/conditionable/composer.json new file mode 100644 index 000000000..c0ddf62e8 --- /dev/null +++ b/src/conditionable/composer.json @@ -0,0 +1,37 @@ +{ + "name": "hypervel/conditionable", + "type": "library", + "description": "The Hypervel Conditionable package.", + "license": "MIT", + "keywords": [ + "php", + "conditionable", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Support\\": "src/" + } + }, + "require": { + "php": "^8.2" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.3-dev" + } + } +} diff --git a/src/conditionable/src/HigherOrderWhenProxy.php b/src/conditionable/src/HigherOrderWhenProxy.php new file mode 100644 index 000000000..1fb3fa7c0 --- /dev/null +++ b/src/conditionable/src/HigherOrderWhenProxy.php @@ -0,0 +1,83 @@ +condition, $this->hasCondition] = [$condition, true]; + + return $this; + } + + /** + * Indicate that the condition should be negated. + */ + public function negateConditionOnCapture(): static + { + $this->negateConditionOnCapture = true; + + return $this; + } + + /** + * Proxy accessing an attribute onto the target. + */ + public function __get(string $key): mixed + { + if (! $this->hasCondition) { + $condition = $this->target->{$key}; + + return $this->condition($this->negateConditionOnCapture ? ! $condition : $condition); + } + + return $this->condition + ? $this->target->{$key} + : $this->target; + } + + /** + * Proxy a method call on the target. + */ + public function __call(string $method, array $parameters): mixed + { + if (! $this->hasCondition) { + $condition = $this->target->{$method}(...$parameters); + + return $this->condition($this->negateConditionOnCapture ? ! $condition : $condition); + } + + return $this->condition + ? $this->target->{$method}(...$parameters) + : $this->target; + } +} diff --git a/src/support/src/Traits/Conditionable.php b/src/conditionable/src/Traits/Conditionable.php similarity index 89% rename from src/support/src/Traits/Conditionable.php rename to src/conditionable/src/Traits/Conditionable.php index 098f55948..fca272289 100644 --- a/src/support/src/Traits/Conditionable.php +++ b/src/conditionable/src/Traits/Conditionable.php @@ -18,10 +18,9 @@ trait Conditionable * @param null|(Closure($this): TWhenParameter)|TWhenParameter $value * @param null|(callable($this, TWhenParameter): TWhenReturnType) $callback * @param null|(callable($this, TWhenParameter): TWhenReturnType) $default - * @param null|mixed $value * @return $this|TWhenReturnType */ - public function when($value = null, ?callable $callback = null, ?callable $default = null) + public function when(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed { $value = $value instanceof Closure ? $value($this) : $value; @@ -52,10 +51,9 @@ public function when($value = null, ?callable $callback = null, ?callable $defau * @param null|(Closure($this): TUnlessParameter)|TUnlessParameter $value * @param null|(callable($this, TUnlessParameter): TUnlessReturnType) $callback * @param null|(callable($this, TUnlessParameter): TUnlessReturnType) $default - * @param null|mixed $value * @return $this|TUnlessReturnType */ - public function unless($value = null, ?callable $callback = null, ?callable $default = null) + public function unless(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed { $value = $value instanceof Closure ? $value($this) : $value; diff --git a/src/config/composer.json b/src/config/composer.json index 89125c658..b3d9d39a7 100644 --- a/src/config/composer.json +++ b/src/config/composer.json @@ -31,7 +31,7 @@ "require": { "php": "^8.2", "hyperf/config": "~3.1.0", - "hyperf/macroable": "~3.1.0" + "hypervel/macroable": "~0.3.0" }, "config": { "sort-packages": true diff --git a/src/config/src/ConfigFactory.php b/src/config/src/ConfigFactory.php index 94cff3440..decec4a20 100644 --- a/src/config/src/ConfigFactory.php +++ b/src/config/src/ConfigFactory.php @@ -4,7 +4,7 @@ namespace Hypervel\Config; -use Hyperf\Collection\Arr; +use Hypervel\Support\Arr; use Psr\Container\ContainerInterface; use Symfony\Component\Finder\Finder; diff --git a/src/config/src/Functions.php b/src/config/src/Functions.php index 9fe456f69..b35bb5b79 100644 --- a/src/config/src/Functions.php +++ b/src/config/src/Functions.php @@ -5,7 +5,7 @@ namespace Hypervel\Config; use Hyperf\Context\ApplicationContext; -use Hypervel\Config\Contracts\Repository as ConfigContract; +use Hypervel\Contracts\Config\Repository as ConfigContract; /** * Get / set the specified configuration value. @@ -13,7 +13,7 @@ * If an array is passed as the key, we will assume you want to set an array of values. * * @param null|array|string $key - * @return ($key is null ? \Hypervel\Config\Contracts\Repository : ($key is string ? mixed : null)) + * @return ($key is null ? \Hypervel\Contracts\Config\Repository : ($key is string ? mixed : null)) */ function config(mixed $key = null, mixed $default = null): mixed { diff --git a/src/config/src/ProviderConfig.php b/src/config/src/ProviderConfig.php index 0bf9c29c6..25ffc0a42 100644 --- a/src/config/src/ProviderConfig.php +++ b/src/config/src/ProviderConfig.php @@ -4,10 +4,10 @@ namespace Hypervel\Config; -use Hyperf\Collection\Arr; use Hyperf\Config\ProviderConfig as HyperfProviderConfig; use Hyperf\Di\Definition\PriorityDefinition; use Hyperf\Support\Composer; +use Hypervel\Support\Arr; use Hypervel\Support\ServiceProvider; use Throwable; diff --git a/src/config/src/Repository.php b/src/config/src/Repository.php index 67a854f66..b64c7b089 100644 --- a/src/config/src/Repository.php +++ b/src/config/src/Repository.php @@ -6,9 +6,9 @@ use ArrayAccess; use Closure; -use Hyperf\Collection\Arr; -use Hyperf\Macroable\Macroable; -use Hypervel\Config\Contracts\Repository as ConfigContract; +use Hypervel\Contracts\Config\Repository as ConfigContract; +use Hypervel\Support\Arr; +use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; class Repository implements ArrayAccess, ConfigContract @@ -16,9 +16,12 @@ class Repository implements ArrayAccess, ConfigContract use Macroable; /** - * Callback for calling after `set` function. + * Callback invoked after each `set` call. + * + * Instance-scoped (not static) so that only the container's Repository + * triggers the callback. Test-created instances won't pollute shared state. */ - protected static ?Closure $afterSettingCallback = null; + protected ?Closure $afterSettingCallback = null; /** * Create a new configuration repository. @@ -167,8 +170,8 @@ public function set(array|string $key, mixed $value = null): void Arr::set($this->items, $key, $value); } - if (static::$afterSettingCallback) { - call_user_func(static::$afterSettingCallback, $keys); + if ($this->afterSettingCallback) { + call_user_func($this->afterSettingCallback, $keys); } } @@ -209,7 +212,7 @@ public function all(): array */ public function afterSettingCallback(?Closure $callback): void { - static::$afterSettingCallback = $callback; + $this->afterSettingCallback = $callback; } /** diff --git a/src/console/src/Application.php b/src/console/src/Application.php index 40598c0bf..ead04236c 100644 --- a/src/console/src/Application.php +++ b/src/console/src/Application.php @@ -6,9 +6,9 @@ use Closure; use Hyperf\Command\Command; -use Hypervel\Console\Contracts\Application as ApplicationContract; -use Hypervel\Container\Contracts\Container as ContainerContract; use Hypervel\Context\Context; +use Hypervel\Contracts\Console\Application as ApplicationContract; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Support\ProcessUtils; use Override; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/src/console/src/ApplicationFactory.php b/src/console/src/ApplicationFactory.php index 8da5d1ba9..d77cf507e 100644 --- a/src/console/src/ApplicationFactory.php +++ b/src/console/src/ApplicationFactory.php @@ -4,7 +4,7 @@ namespace Hypervel\Console; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; use Psr\Container\ContainerInterface; use Throwable; diff --git a/src/console/src/CacheCommandMutex.php b/src/console/src/CacheCommandMutex.php new file mode 100644 index 000000000..d163c84e0 --- /dev/null +++ b/src/console/src/CacheCommandMutex.php @@ -0,0 +1,111 @@ +cache->store($this->store); + + $expiresAt = method_exists($command, 'isolationLockExpiresAt') + ? $command->isolationLockExpiresAt() + : CarbonInterval::hour(); + + $cacheStore = $store->getStore(); + + if ($cacheStore instanceof LockProvider) { + return $cacheStore->lock( + $this->commandMutexName($command), + $this->secondsUntil($expiresAt) + )->get(); + } + + return $store->add($this->commandMutexName($command), true, $expiresAt); + } + + /** + * Determine if a command mutex exists for the given command. + */ + public function exists(Command $command): bool + { + $store = $this->cache->store($this->store); + + $cacheStore = $store->getStore(); + + if ($cacheStore instanceof LockProvider) { + $lock = $cacheStore->lock($this->commandMutexName($command)); + + return tap(! $lock->get(), function ($exists) use ($lock) { + if ($exists) { + $lock->release(); + } + }); + } + + return $this->cache->store($this->store)->has($this->commandMutexName($command)); + } + + /** + * Release the mutex for the given command. + */ + public function forget(Command $command): bool + { + $store = $this->cache->store($this->store); + + $cacheStore = $store->getStore(); + + if ($cacheStore instanceof LockProvider) { + $cacheStore->lock($this->commandMutexName($command))->forceRelease(); + + return true; + } + + return $this->cache->store($this->store)->forget($this->commandMutexName($command)); + } + + /** + * Get the isolatable command mutex name. + */ + protected function commandMutexName(Command $command): string + { + $baseName = 'framework' . DIRECTORY_SEPARATOR . 'command-' . $command->getName(); + + return method_exists($command, 'isolatableId') + ? $baseName . '-' . $command->isolatableId() + : $baseName; + } + + /** + * Specify the cache store that should be used. + */ + public function useStore(?string $store): static + { + $this->store = $store; + + return $this; + } +} diff --git a/src/console/src/ClosureCommand.php b/src/console/src/ClosureCommand.php index 5af6abac4..f7fe15b96 100644 --- a/src/console/src/ClosureCommand.php +++ b/src/console/src/ClosureCommand.php @@ -8,7 +8,7 @@ use Closure; use Hyperf\Support\Traits\ForwardsCalls; use Hypervel\Console\Scheduling\Event; -use Hypervel\Container\Contracts\Container as ContainerContract; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Support\Facades\Schedule; use ReflectionFunction; diff --git a/src/console/src/Command.php b/src/console/src/Command.php index 2fb62ce4b..138cb4339 100644 --- a/src/console/src/Command.php +++ b/src/console/src/Command.php @@ -11,12 +11,15 @@ use Hyperf\Command\Event\AfterHandle; use Hyperf\Command\Event\BeforeHandle; use Hyperf\Command\Event\FailToHandle; +use Hypervel\Console\Contracts\CommandMutex; use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Console\Isolatable; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Coroutine\Coroutine; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; use Swoole\ExitException; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Throwable; @@ -29,6 +32,16 @@ abstract class Command extends HyperfCommand protected ApplicationContract $app; + /** + * Indicates whether only one instance of the command can run at any given time. + */ + protected bool $isolated = false; + + /** + * The default exit code for isolated commands. + */ + protected int $isolatedExitCode = self::SUCCESS; + public function __construct(?string $name = null) { parent::__construct($name); @@ -36,12 +49,46 @@ public function __construct(?string $name = null) /** @var ApplicationContract $app */ $app = ApplicationContext::getContainer(); $this->app = $app; + + if ($this instanceof Isolatable) { + $this->configureIsolation(); + } + } + + /** + * Configure the console command for isolation. + */ + protected function configureIsolation(): void + { + $this->getDefinition()->addOption(new InputOption( + 'isolated', + null, + InputOption::VALUE_OPTIONAL, + 'Do not run the command if another instance of the command is already running', + $this->isolated + )); } protected function execute(InputInterface $input, OutputInterface $output): int { $this->disableDispatcher($input); $this->replaceOutput(); + + // Check if the command should be isolated and if another instance is running + if ($this instanceof Isolatable + && $this->option('isolated') !== false + && ! $this->commandIsolationMutex()->create($this) + ) { + $this->comment(sprintf( + 'The [%s] command is already running.', + $this->getName() + )); + + return (int) (is_numeric($this->option('isolated')) + ? $this->option('isolated') + : $this->isolatedExitCode); + } + $method = method_exists($this, 'handle') ? 'handle' : '__invoke'; $callback = function () use ($method): int { @@ -74,6 +121,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->eventDispatcher->dispatch(new FailToHandle($this, $exception)); } finally { $this->eventDispatcher?->dispatch(new AfterExecute($this, $exception ?? null)); + + // Release the isolation mutex if applicable + if ($this instanceof Isolatable && $this->option('isolated') !== false) { + $this->commandIsolationMutex()->forget($this); + } } return $this->exitCode; @@ -88,6 +140,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $this->exitCode >= 0 && $this->exitCode <= 255 ? $this->exitCode : self::INVALID; } + /** + * Get a command isolation mutex instance for the command. + */ + protected function commandIsolationMutex(): CommandMutex + { + return $this->app->bound(CommandMutex::class) + ? $this->app->get(CommandMutex::class) + : $this->app->get(CacheCommandMutex::class); + } + protected function replaceOutput(): void { /* @phpstan-ignore-next-line */ diff --git a/src/console/src/Commands/ScheduleListCommand.php b/src/console/src/Commands/ScheduleListCommand.php index eac35f280..cea3e37fe 100644 --- a/src/console/src/Commands/ScheduleListCommand.php +++ b/src/console/src/Commands/ScheduleListCommand.php @@ -8,12 +8,12 @@ use Cron\CronExpression; use DateTimeZone; use Exception; -use Hyperf\Collection\Collection; use Hypervel\Console\Command; use Hypervel\Console\Scheduling\CallbackEvent; use Hypervel\Console\Scheduling\Event; use Hypervel\Console\Scheduling\Schedule; use Hypervel\Support\Carbon; +use Hypervel\Support\Collection; use ReflectionClass; use ReflectionFunction; use Symfony\Component\Console\Terminal; diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index 0c6174fc9..1f0185bc6 100644 --- a/src/console/src/Commands/ScheduleRunCommand.php +++ b/src/console/src/Commands/ScheduleRunCommand.php @@ -4,8 +4,6 @@ namespace Hypervel\Console\Commands; -use Hyperf\Collection\Collection; -use Hypervel\Cache\Contracts\Factory as CacheFactory; use Hypervel\Console\Command; use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; @@ -14,10 +12,12 @@ use Hypervel\Console\Scheduling\CallbackEvent; use Hypervel\Console\Scheduling\Event; use Hypervel\Console\Scheduling\Schedule; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Coroutine\Concurrent; use Hypervel\Coroutine\Waiter; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; use Hypervel\Support\Carbon; +use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; use Hypervel\Support\Sleep; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/src/console/src/Commands/ScheduleStopCommand.php b/src/console/src/Commands/ScheduleStopCommand.php index 67bf2a406..0e9c1159b 100644 --- a/src/console/src/Commands/ScheduleStopCommand.php +++ b/src/console/src/Commands/ScheduleStopCommand.php @@ -4,8 +4,8 @@ namespace Hypervel\Console\Commands; -use Hypervel\Cache\Contracts\Factory as CacheFactory; use Hypervel\Console\Command; +use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Support\Facades\Date; class ScheduleStopCommand extends Command diff --git a/src/console/src/ConfigProvider.php b/src/console/src/ConfigProvider.php index 454763d54..461d004cc 100644 --- a/src/console/src/ConfigProvider.php +++ b/src/console/src/ConfigProvider.php @@ -9,12 +9,16 @@ use Hypervel\Console\Commands\ScheduleRunCommand; use Hypervel\Console\Commands\ScheduleStopCommand; use Hypervel\Console\Commands\ScheduleTestCommand; +use Hypervel\Console\Contracts\CommandMutex; class ConfigProvider { public function __invoke(): array { return [ + 'dependencies' => [ + CommandMutex::class => CacheCommandMutex::class, + ], 'commands' => [ ScheduleListCommand::class, ScheduleRunCommand::class, diff --git a/src/console/src/ConfirmableTrait.php b/src/console/src/ConfirmableTrait.php index 3b8afcf11..3681a3bde 100644 --- a/src/console/src/ConfirmableTrait.php +++ b/src/console/src/ConfirmableTrait.php @@ -6,7 +6,7 @@ use Closure; use Hypervel\Context\ApplicationContext; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use function Hyperf\Support\value; diff --git a/src/console/src/Contracts/CommandMutex.php b/src/console/src/Contracts/CommandMutex.php new file mode 100644 index 000000000..594cf55a4 --- /dev/null +++ b/src/console/src/Contracts/CommandMutex.php @@ -0,0 +1,25 @@ +components->warn('This command is prohibited from running in this environment.'); + } + + return true; + } +} diff --git a/src/console/src/Scheduling/CacheEventMutex.php b/src/console/src/Scheduling/CacheEventMutex.php index 8dda537ee..8a2778faa 100644 --- a/src/console/src/Scheduling/CacheEventMutex.php +++ b/src/console/src/Scheduling/CacheEventMutex.php @@ -4,11 +4,11 @@ namespace Hypervel\Console\Scheduling; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Cache\Contracts\LockProvider; -use Hypervel\Cache\Contracts\Store; use Hypervel\Console\Contracts\CacheAware; use Hypervel\Console\Contracts\EventMutex; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Cache\LockProvider; +use Hypervel\Contracts\Cache\Store; class CacheEventMutex implements EventMutex, CacheAware { diff --git a/src/console/src/Scheduling/CacheSchedulingMutex.php b/src/console/src/Scheduling/CacheSchedulingMutex.php index d2a852559..1a8e70fe8 100644 --- a/src/console/src/Scheduling/CacheSchedulingMutex.php +++ b/src/console/src/Scheduling/CacheSchedulingMutex.php @@ -5,9 +5,9 @@ namespace Hypervel\Console\Scheduling; use DateTimeInterface; -use Hypervel\Cache\Contracts\Factory as CacheFactory; use Hypervel\Console\Contracts\CacheAware; use Hypervel\Console\Contracts\SchedulingMutex; +use Hypervel\Contracts\Cache\Factory as CacheFactory; class CacheSchedulingMutex implements SchedulingMutex, CacheAware { diff --git a/src/console/src/Scheduling/CallbackEvent.php b/src/console/src/Scheduling/CallbackEvent.php index 146c9f281..14be1f0b6 100644 --- a/src/console/src/Scheduling/CallbackEvent.php +++ b/src/console/src/Scheduling/CallbackEvent.php @@ -6,7 +6,7 @@ use DateTimeZone; use Hypervel\Console\Contracts\EventMutex; -use Hypervel\Container\Contracts\Container; +use Hypervel\Contracts\Container\Container; use Hypervel\Support\Reflector; use InvalidArgumentException; use LogicException; diff --git a/src/console/src/Scheduling/Event.php b/src/console/src/Scheduling/Event.php index 60912fb2c..699997f40 100644 --- a/src/console/src/Scheduling/Event.php +++ b/src/console/src/Scheduling/Event.php @@ -13,20 +13,20 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\ClientInterface as HttpClientInterface; use GuzzleHttp\Exception\TransferException; -use Hyperf\Collection\Arr; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Stringable; use Hyperf\Support\Filesystem\Filesystem; -use Hyperf\Tappable\Tappable; use Hypervel\Console\Contracts\EventMutex; -use Hypervel\Container\Contracts\Container; use Hypervel\Context\Context; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; -use Hypervel\Mail\Contracts\Mailer; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Contracts\Mail\Mailer; +use Hypervel\Support\Arr; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Stringable; +use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Traits\ReflectsClosures; +use Hypervel\Support\Traits\Tappable; use LogicException; use Psr\Http\Client\ClientExceptionInterface; use Symfony\Component\Process\Process; @@ -184,7 +184,7 @@ protected function execute(Container $container): int */ protected function runProcess(Container $container): int { - /** @var \Hypervel\Foundation\Contracts\Application $container */ + /** @var \Hypervel\Contracts\Foundation\Application $container */ $process = Process::fromShellCommandline( $this->command, $container->basePath() diff --git a/src/console/src/Scheduling/ManagesFrequencies.php b/src/console/src/Scheduling/ManagesFrequencies.php index 6f70ca29b..950d760ae 100644 --- a/src/console/src/Scheduling/ManagesFrequencies.php +++ b/src/console/src/Scheduling/ManagesFrequencies.php @@ -502,8 +502,6 @@ public function yearly(): static /** * Schedule the event to run yearly on a given month, day, and time. - * - * @param int|string|string $dayOfMonth */ public function yearlyOn(int $month = 1, int|string $dayOfMonth = 1, string $time = '0:0'): static { diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php index f79b428dd..adc3cc7a5 100644 --- a/src/console/src/Scheduling/Schedule.php +++ b/src/console/src/Scheduling/Schedule.php @@ -8,22 +8,22 @@ use Closure; use DateTimeInterface; use DateTimeZone; -use Hyperf\Collection\Collection; -use Hyperf\Macroable\Macroable; -use Hypervel\Bus\Contracts\Dispatcher; use Hypervel\Bus\UniqueLock; -use Hypervel\Cache\Contracts\Factory as CacheFactory; use Hypervel\Console\Contracts\CacheAware; use Hypervel\Console\Contracts\EventMutex; use Hypervel\Console\Contracts\SchedulingMutex; use Hypervel\Container\BindingResolutionException; use Hypervel\Container\Container; use Hypervel\Context\ApplicationContext; -use Hypervel\Foundation\Contracts\Application; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Foundation\Application; +use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\CallQueuedClosure; -use Hypervel\Queue\Contracts\ShouldBeUnique; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Support\Collection; use Hypervel\Support\ProcessUtils; +use Hypervel\Support\Traits\Macroable; use RuntimeException; use UnitEnum; diff --git a/src/container/src/BoundMethod.php b/src/container/src/BoundMethod.php index eee19c551..f5077372c 100644 --- a/src/container/src/BoundMethod.php +++ b/src/container/src/BoundMethod.php @@ -8,7 +8,7 @@ use Hyperf\Contract\NormalizerInterface; use Hyperf\Di\ClosureDefinitionCollectorInterface; use Hyperf\Di\MethodDefinitionCollectorInterface; -use Hypervel\Container\Contracts\Container as ContainerContract; +use Hypervel\Contracts\Container\Container as ContainerContract; use InvalidArgumentException; use ReflectionException; diff --git a/src/container/src/Container.php b/src/container/src/Container.php index 2c85a3b19..533182f56 100644 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -9,7 +9,7 @@ use Hyperf\Context\ApplicationContext; use Hyperf\Di\Container as HyperfContainer; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Container\Contracts\Container as ContainerContract; +use Hypervel\Contracts\Container\Container as ContainerContract; use InvalidArgumentException; use LogicException; use TypeError; @@ -258,6 +258,17 @@ public function bind(string $abstract, mixed $concrete = null): void $this->define($abstract, $concrete); } + /** + * Register a shared binding in the container. + * + * @temporary This is an alias for bind() until Laravel's container is ported. + * In Hyperf/Swoole, all bindings are singletons by default. + */ + public function singleton(string $abstract, mixed $concrete = null): void + { + $this->bind($abstract, $concrete); + } + /** * Determine if the container has a method binding. */ diff --git a/src/contracts/LICENSE.md b/src/contracts/LICENSE.md new file mode 100644 index 000000000..670aace44 --- /dev/null +++ b/src/contracts/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +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. \ No newline at end of file diff --git a/src/contracts/README.md b/src/contracts/README.md new file mode 100644 index 000000000..adee9bee6 --- /dev/null +++ b/src/contracts/README.md @@ -0,0 +1,4 @@ +Contracts for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/contracts) \ No newline at end of file diff --git a/src/contracts/composer.json b/src/contracts/composer.json new file mode 100644 index 000000000..af9afb5be --- /dev/null +++ b/src/contracts/composer.json @@ -0,0 +1,39 @@ +{ + "name": "hypervel/contracts", + "type": "library", + "description": "The contracts package for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "hyperf", + "contracts", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Contracts\\": "src/" + } + }, + "require": { + "php": "^8.2" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.3-dev" + } + } +} diff --git a/src/auth/src/Contracts/Authorizable.php b/src/contracts/src/Auth/Access/Authorizable.php similarity index 83% rename from src/auth/src/Contracts/Authorizable.php rename to src/contracts/src/Auth/Access/Authorizable.php index 6e615f2c7..6641b00c4 100644 --- a/src/auth/src/Contracts/Authorizable.php +++ b/src/contracts/src/Auth/Access/Authorizable.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Auth\Contracts; +namespace Hypervel\Contracts\Auth\Access; interface Authorizable { diff --git a/src/auth/src/Contracts/Gate.php b/src/contracts/src/Auth/Access/Gate.php similarity index 97% rename from src/auth/src/Contracts/Gate.php rename to src/contracts/src/Auth/Access/Gate.php index a3ea09739..ff9b1ea8a 100644 --- a/src/auth/src/Contracts/Gate.php +++ b/src/contracts/src/Auth/Access/Gate.php @@ -2,10 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Auth\Contracts; +namespace Hypervel\Contracts\Auth\Access; use Hypervel\Auth\Access\AuthorizationException; use Hypervel\Auth\Access\Response; +use Hypervel\Contracts\Auth\Authenticatable; use InvalidArgumentException; use UnitEnum; diff --git a/src/auth/src/Contracts/Authenticatable.php b/src/contracts/src/Auth/Authenticatable.php similarity index 92% rename from src/auth/src/Contracts/Authenticatable.php rename to src/contracts/src/Auth/Authenticatable.php index 2f0412073..f7aa2223b 100644 --- a/src/auth/src/Contracts/Authenticatable.php +++ b/src/contracts/src/Auth/Authenticatable.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Auth\Contracts; +namespace Hypervel\Contracts\Auth; interface Authenticatable { diff --git a/src/auth/src/Contracts/Factory.php b/src/contracts/src/Auth/Factory.php similarity index 89% rename from src/auth/src/Contracts/Factory.php rename to src/contracts/src/Auth/Factory.php index 076af825f..69948b01c 100644 --- a/src/auth/src/Contracts/Factory.php +++ b/src/contracts/src/Auth/Factory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Auth\Contracts; +namespace Hypervel\Contracts\Auth; interface Factory { diff --git a/src/auth/src/Contracts/Guard.php b/src/contracts/src/Auth/Guard.php similarity index 95% rename from src/auth/src/Contracts/Guard.php rename to src/contracts/src/Auth/Guard.php index ed670d703..5c73538b7 100644 --- a/src/auth/src/Contracts/Guard.php +++ b/src/contracts/src/Auth/Guard.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Auth\Contracts; +namespace Hypervel\Contracts\Auth; interface Guard { diff --git a/src/auth/src/Contracts/StatefulGuard.php b/src/contracts/src/Auth/StatefulGuard.php similarity index 96% rename from src/auth/src/Contracts/StatefulGuard.php rename to src/contracts/src/Auth/StatefulGuard.php index 4ab807dde..8b22b6716 100644 --- a/src/auth/src/Contracts/StatefulGuard.php +++ b/src/contracts/src/Auth/StatefulGuard.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Auth\Contracts; +namespace Hypervel\Contracts\Auth; interface StatefulGuard extends Guard { diff --git a/src/auth/src/Contracts/UserProvider.php b/src/contracts/src/Auth/UserProvider.php similarity index 93% rename from src/auth/src/Contracts/UserProvider.php rename to src/contracts/src/Auth/UserProvider.php index 76996cde9..ef9185fd9 100644 --- a/src/auth/src/Contracts/UserProvider.php +++ b/src/contracts/src/Auth/UserProvider.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Auth\Contracts; +namespace Hypervel\Contracts\Auth; interface UserProvider { diff --git a/src/broadcasting/src/Contracts/Broadcaster.php b/src/contracts/src/Broadcasting/Broadcaster.php similarity index 92% rename from src/broadcasting/src/Contracts/Broadcaster.php rename to src/contracts/src/Broadcasting/Broadcaster.php index d7c09468c..7e5ca3341 100644 --- a/src/broadcasting/src/Contracts/Broadcaster.php +++ b/src/contracts/src/Broadcasting/Broadcaster.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Broadcasting\Contracts; +namespace Hypervel\Contracts\Broadcasting; use Hyperf\HttpServer\Contract\RequestInterface; diff --git a/src/broadcasting/src/Contracts/Factory.php b/src/contracts/src/Broadcasting/Factory.php similarity index 81% rename from src/broadcasting/src/Contracts/Factory.php rename to src/contracts/src/Broadcasting/Factory.php index 2750481c5..af77b2302 100644 --- a/src/broadcasting/src/Contracts/Factory.php +++ b/src/contracts/src/Broadcasting/Factory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Broadcasting\Contracts; +namespace Hypervel\Contracts\Broadcasting; interface Factory { diff --git a/src/broadcasting/src/Contracts/HasBroadcastChannel.php b/src/contracts/src/Broadcasting/HasBroadcastChannel.php similarity index 89% rename from src/broadcasting/src/Contracts/HasBroadcastChannel.php rename to src/contracts/src/Broadcasting/HasBroadcastChannel.php index 009822df6..981020483 100644 --- a/src/broadcasting/src/Contracts/HasBroadcastChannel.php +++ b/src/contracts/src/Broadcasting/HasBroadcastChannel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Broadcasting\Contracts; +namespace Hypervel\Contracts\Broadcasting; interface HasBroadcastChannel { diff --git a/src/broadcasting/src/Contracts/ShouldBeUnique.php b/src/contracts/src/Broadcasting/ShouldBeUnique.php similarity index 59% rename from src/broadcasting/src/Contracts/ShouldBeUnique.php rename to src/contracts/src/Broadcasting/ShouldBeUnique.php index 4c062d824..18bfc0211 100644 --- a/src/broadcasting/src/Contracts/ShouldBeUnique.php +++ b/src/contracts/src/Broadcasting/ShouldBeUnique.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Broadcasting\Contracts; +namespace Hypervel\Contracts\Broadcasting; interface ShouldBeUnique { diff --git a/src/broadcasting/src/Contracts/ShouldBroadcast.php b/src/contracts/src/Broadcasting/ShouldBroadcast.php similarity index 86% rename from src/broadcasting/src/Contracts/ShouldBroadcast.php rename to src/contracts/src/Broadcasting/ShouldBroadcast.php index 59033cddd..9a2ed7a46 100644 --- a/src/broadcasting/src/Contracts/ShouldBroadcast.php +++ b/src/contracts/src/Broadcasting/ShouldBroadcast.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Broadcasting\Contracts; +namespace Hypervel\Contracts\Broadcasting; use Hypervel\Broadcasting\Channel; diff --git a/src/broadcasting/src/Contracts/ShouldBroadcastNow.php b/src/contracts/src/Broadcasting/ShouldBroadcastNow.php similarity index 67% rename from src/broadcasting/src/Contracts/ShouldBroadcastNow.php rename to src/contracts/src/Broadcasting/ShouldBroadcastNow.php index d24be2ab5..a88b116fe 100644 --- a/src/broadcasting/src/Contracts/ShouldBroadcastNow.php +++ b/src/contracts/src/Broadcasting/ShouldBroadcastNow.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Broadcasting\Contracts; +namespace Hypervel\Contracts\Broadcasting; interface ShouldBroadcastNow extends ShouldBroadcast { diff --git a/src/bus/src/Contracts/BatchRepository.php b/src/contracts/src/Bus/BatchRepository.php similarity index 98% rename from src/bus/src/Contracts/BatchRepository.php rename to src/contracts/src/Bus/BatchRepository.php index 703a44f57..b86fc0e12 100644 --- a/src/bus/src/Contracts/BatchRepository.php +++ b/src/contracts/src/Bus/BatchRepository.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Bus\Contracts; +namespace Hypervel\Contracts\Bus; use Closure; use Hypervel\Bus\Batch; diff --git a/src/bus/src/Contracts/Dispatcher.php b/src/contracts/src/Bus/Dispatcher.php similarity index 96% rename from src/bus/src/Contracts/Dispatcher.php rename to src/contracts/src/Bus/Dispatcher.php index b5d46e918..0368b148e 100644 --- a/src/bus/src/Contracts/Dispatcher.php +++ b/src/contracts/src/Bus/Dispatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Bus\Contracts; +namespace Hypervel\Contracts\Bus; interface Dispatcher { diff --git a/src/bus/src/Contracts/PrunableBatchRepository.php b/src/contracts/src/Bus/PrunableBatchRepository.php similarity index 88% rename from src/bus/src/Contracts/PrunableBatchRepository.php rename to src/contracts/src/Bus/PrunableBatchRepository.php index 932880eea..acce14214 100644 --- a/src/bus/src/Contracts/PrunableBatchRepository.php +++ b/src/contracts/src/Bus/PrunableBatchRepository.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Bus\Contracts; +namespace Hypervel\Contracts\Bus; use DateTimeInterface; diff --git a/src/bus/src/Contracts/QueueingDispatcher.php b/src/contracts/src/Bus/QueueingDispatcher.php similarity index 88% rename from src/bus/src/Contracts/QueueingDispatcher.php rename to src/contracts/src/Bus/QueueingDispatcher.php index a16fb6181..55f436ab5 100644 --- a/src/bus/src/Contracts/QueueingDispatcher.php +++ b/src/contracts/src/Bus/QueueingDispatcher.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Bus\Contracts; +namespace Hypervel\Contracts\Bus; -use Hyperf\Collection\Collection; use Hypervel\Bus\Batch; use Hypervel\Bus\PendingBatch; +use Hypervel\Support\Collection; interface QueueingDispatcher extends Dispatcher { diff --git a/src/cache/src/Contracts/Factory.php b/src/contracts/src/Cache/Factory.php similarity index 83% rename from src/cache/src/Contracts/Factory.php rename to src/contracts/src/Cache/Factory.php index cd5141a1c..56752aaf0 100644 --- a/src/cache/src/Contracts/Factory.php +++ b/src/contracts/src/Cache/Factory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Cache\Contracts; +namespace Hypervel\Contracts\Cache; interface Factory { diff --git a/src/cache/src/Exceptions/InvalidArgumentException.php b/src/contracts/src/Cache/InvalidArgumentException.php similarity index 73% rename from src/cache/src/Exceptions/InvalidArgumentException.php rename to src/contracts/src/Cache/InvalidArgumentException.php index f03c85621..5057e1bd5 100644 --- a/src/cache/src/Exceptions/InvalidArgumentException.php +++ b/src/contracts/src/Cache/InvalidArgumentException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Cache\Exceptions; +namespace Hypervel\Contracts\Cache; class InvalidArgumentException extends \InvalidArgumentException { diff --git a/src/cache/src/Contracts/Lock.php b/src/contracts/src/Cache/Lock.php similarity index 94% rename from src/cache/src/Contracts/Lock.php rename to src/contracts/src/Cache/Lock.php index 98a2669a3..691f0c1f6 100644 --- a/src/cache/src/Contracts/Lock.php +++ b/src/contracts/src/Cache/Lock.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Cache\Contracts; +namespace Hypervel\Contracts\Cache; interface Lock { diff --git a/src/cache/src/Contracts/LockProvider.php b/src/contracts/src/Cache/LockProvider.php similarity index 90% rename from src/cache/src/Contracts/LockProvider.php rename to src/contracts/src/Cache/LockProvider.php index 4f823f311..b194b2981 100644 --- a/src/cache/src/Contracts/LockProvider.php +++ b/src/contracts/src/Cache/LockProvider.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Cache\Contracts; +namespace Hypervel\Contracts\Cache; interface LockProvider { diff --git a/src/cache/src/Exceptions/LockTimeoutException.php b/src/contracts/src/Cache/LockTimeoutException.php similarity index 72% rename from src/cache/src/Exceptions/LockTimeoutException.php rename to src/contracts/src/Cache/LockTimeoutException.php index 29853f021..f60c05fb2 100644 --- a/src/cache/src/Exceptions/LockTimeoutException.php +++ b/src/contracts/src/Cache/LockTimeoutException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Cache\Exceptions; +namespace Hypervel\Contracts\Cache; use Exception; diff --git a/src/cache/src/Contracts/RefreshableLock.php b/src/contracts/src/Cache/RefreshableLock.php similarity index 97% rename from src/cache/src/Contracts/RefreshableLock.php rename to src/contracts/src/Cache/RefreshableLock.php index fe3ae3c98..3abaffeda 100644 --- a/src/cache/src/Contracts/RefreshableLock.php +++ b/src/contracts/src/Cache/RefreshableLock.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Cache\Contracts; +namespace Hypervel\Contracts\Cache; use InvalidArgumentException; diff --git a/src/cache/src/Contracts/Repository.php b/src/contracts/src/Cache/Repository.php similarity index 98% rename from src/cache/src/Contracts/Repository.php rename to src/contracts/src/Cache/Repository.php index 836dcd42f..9298aaea8 100644 --- a/src/cache/src/Contracts/Repository.php +++ b/src/contracts/src/Cache/Repository.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Cache\Contracts; +namespace Hypervel\Contracts\Cache; use Closure; use DateInterval; diff --git a/src/cache/src/Contracts/Store.php b/src/contracts/src/Cache/Store.php similarity index 97% rename from src/cache/src/Contracts/Store.php rename to src/contracts/src/Cache/Store.php index 28cb5f26c..bd83d13bd 100644 --- a/src/cache/src/Contracts/Store.php +++ b/src/contracts/src/Cache/Store.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Cache\Contracts; +namespace Hypervel\Contracts\Cache; interface Store { diff --git a/src/config/src/Contracts/Repository.php b/src/contracts/src/Config/Repository.php similarity index 96% rename from src/config/src/Contracts/Repository.php rename to src/contracts/src/Config/Repository.php index 809eec8e0..100072e04 100644 --- a/src/config/src/Contracts/Repository.php +++ b/src/contracts/src/Config/Repository.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Config\Contracts; +namespace Hypervel\Contracts\Config; use Closure; use Hyperf\Contract\ConfigInterface; diff --git a/src/console/src/Contracts/Application.php b/src/contracts/src/Console/Application.php similarity index 94% rename from src/console/src/Contracts/Application.php rename to src/contracts/src/Console/Application.php index 655569133..cb962e2e1 100644 --- a/src/console/src/Contracts/Application.php +++ b/src/contracts/src/Console/Application.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Console\Contracts; +namespace Hypervel\Contracts\Console; use Closure; use Hyperf\Command\Command; -use Hypervel\Container\Contracts\Container as ContainerContract; +use Hypervel\Contracts\Container\Container as ContainerContract; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/contracts/src/Console/Isolatable.php b/src/contracts/src/Console/Isolatable.php new file mode 100644 index 000000000..672bd5547 --- /dev/null +++ b/src/contracts/src/Console/Isolatable.php @@ -0,0 +1,15 @@ + + */ + public static function castUsing(array $arguments); +} diff --git a/src/contracts/src/Database/Eloquent/CastsAttributes.php b/src/contracts/src/Database/Eloquent/CastsAttributes.php new file mode 100644 index 000000000..4d31d600c --- /dev/null +++ b/src/contracts/src/Database/Eloquent/CastsAttributes.php @@ -0,0 +1,31 @@ + $attributes + * @return null|TGet + */ + public function get(Model $model, string $key, mixed $value, array $attributes); + + /** + * Transform the attribute to its underlying model values. + * + * @param null|TSet $value + * @param array $attributes + * @return mixed + */ + public function set(Model $model, string $key, mixed $value, array $attributes); +} diff --git a/src/contracts/src/Database/Eloquent/CastsInboundAttributes.php b/src/contracts/src/Database/Eloquent/CastsInboundAttributes.php new file mode 100644 index 000000000..18892740b --- /dev/null +++ b/src/contracts/src/Database/Eloquent/CastsInboundAttributes.php @@ -0,0 +1,18 @@ + $attributes + * @return mixed + */ + public function set(Model $model, string $key, mixed $value, array $attributes); +} diff --git a/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php b/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php new file mode 100644 index 000000000..3b7c944f1 --- /dev/null +++ b/src/contracts/src/Database/Eloquent/SupportsPartialRelations.php @@ -0,0 +1,28 @@ + + */ + public function items(): array; + + /** + * Get the "cursor" of the previous set of items. + */ + public function previousCursor(): ?Cursor; + + /** + * Get the "cursor" of the next set of items. + */ + public function nextCursor(): ?Cursor; + + /** + * Determine how many items are being shown per page. + */ + public function perPage(): int; + + /** + * Get the current cursor being paginated. + */ + public function cursor(): ?Cursor; + + /** + * Determine if there are enough items to split into multiple pages. + */ + public function hasPages(): bool; + + /** + * Determine if there are more items in the data source. + */ + public function hasMorePages(): bool; + + /** + * Get the base path for paginator generated URLs. + */ + public function path(): ?string; + + /** + * Determine if the list of items is empty or not. + */ + public function isEmpty(): bool; + + /** + * Determine if the list of items is not empty. + */ + public function isNotEmpty(): bool; + + /** + * Render the paginator using a given view. + * + * @param array $data + */ + public function render(?string $view = null, array $data = []): Htmlable; +} diff --git a/src/contracts/src/Pagination/LengthAwarePaginator.php b/src/contracts/src/Pagination/LengthAwarePaginator.php new file mode 100644 index 000000000..3ef9467ee --- /dev/null +++ b/src/contracts/src/Pagination/LengthAwarePaginator.php @@ -0,0 +1,32 @@ + + */ +interface LengthAwarePaginator extends Paginator +{ + /** + * Create a range of pagination URLs. + * + * @return array + */ + public function getUrlRange(int $start, int $end): array; + + /** + * Determine the total number of items in the data store. + */ + public function total(): int; + + /** + * Get the page number of the last available page. + */ + public function lastPage(): int; +} diff --git a/src/contracts/src/Pagination/Paginator.php b/src/contracts/src/Pagination/Paginator.php new file mode 100644 index 000000000..feaea24c7 --- /dev/null +++ b/src/contracts/src/Pagination/Paginator.php @@ -0,0 +1,112 @@ + + */ + public function items(): array; + + /** + * Get the "index" of the first item being paginated. + */ + public function firstItem(): ?int; + + /** + * Get the "index" of the last item being paginated. + */ + public function lastItem(): ?int; + + /** + * Determine how many items are being shown per page. + */ + public function perPage(): int; + + /** + * Determine the current page being paginated. + */ + public function currentPage(): int; + + /** + * Determine if there are enough items to split into multiple pages. + */ + public function hasPages(): bool; + + /** + * Determine if there are more items in the data store. + */ + public function hasMorePages(): bool; + + /** + * Get the base path for paginator generated URLs. + */ + public function path(): ?string; + + /** + * Determine if the list of items is empty or not. + */ + public function isEmpty(): bool; + + /** + * Determine if the list of items is not empty. + */ + public function isNotEmpty(): bool; + + /** + * Render the paginator using a given view. + * + * @param array $data + */ + public function render(?string $view = null, array $data = []): Htmlable; +} diff --git a/src/queue/src/Contracts/ClearableQueue.php b/src/contracts/src/Queue/ClearableQueue.php similarity index 82% rename from src/queue/src/Contracts/ClearableQueue.php rename to src/contracts/src/Queue/ClearableQueue.php index 3c2b3e416..b6e68c8b0 100644 --- a/src/queue/src/Contracts/ClearableQueue.php +++ b/src/contracts/src/Queue/ClearableQueue.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface ClearableQueue { diff --git a/src/queue/src/Contracts/EntityResolver.php b/src/contracts/src/Queue/EntityResolver.php similarity index 83% rename from src/queue/src/Contracts/EntityResolver.php rename to src/contracts/src/Queue/EntityResolver.php index d56d77ed0..a33f85916 100644 --- a/src/queue/src/Contracts/EntityResolver.php +++ b/src/contracts/src/Queue/EntityResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface EntityResolver { diff --git a/src/queue/src/Contracts/Factory.php b/src/contracts/src/Queue/Factory.php similarity index 83% rename from src/queue/src/Contracts/Factory.php rename to src/contracts/src/Queue/Factory.php index 334785abc..1f78ecb33 100644 --- a/src/queue/src/Contracts/Factory.php +++ b/src/contracts/src/Queue/Factory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface Factory { diff --git a/src/queue/src/Contracts/Job.php b/src/contracts/src/Queue/Job.php similarity index 98% rename from src/queue/src/Contracts/Job.php rename to src/contracts/src/Queue/Job.php index 22a7cc9e1..42aa9c02c 100644 --- a/src/queue/src/Contracts/Job.php +++ b/src/contracts/src/Queue/Job.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; use Throwable; diff --git a/src/queue/src/Contracts/Monitor.php b/src/contracts/src/Queue/Monitor.php similarity index 93% rename from src/queue/src/Contracts/Monitor.php rename to src/contracts/src/Queue/Monitor.php index 3ce389427..d24228935 100644 --- a/src/queue/src/Contracts/Monitor.php +++ b/src/contracts/src/Queue/Monitor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface Monitor { diff --git a/src/queue/src/Contracts/Queue.php b/src/contracts/src/Queue/Queue.php similarity index 98% rename from src/queue/src/Contracts/Queue.php rename to src/contracts/src/Queue/Queue.php index 27bc46e1c..5a61bd0f4 100644 --- a/src/queue/src/Contracts/Queue.php +++ b/src/contracts/src/Queue/Queue.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; use DateInterval; use DateTimeInterface; diff --git a/src/queue/src/Contracts/QueueableCollection.php b/src/contracts/src/Queue/QueueableCollection.php similarity index 94% rename from src/queue/src/Contracts/QueueableCollection.php rename to src/contracts/src/Queue/QueueableCollection.php index 221a21684..860816579 100644 --- a/src/queue/src/Contracts/QueueableCollection.php +++ b/src/contracts/src/Queue/QueueableCollection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface QueueableCollection { diff --git a/src/queue/src/Contracts/QueueableEntity.php b/src/contracts/src/Queue/QueueableEntity.php similarity index 91% rename from src/queue/src/Contracts/QueueableEntity.php rename to src/contracts/src/Queue/QueueableEntity.php index 0df79bd84..d13ddbd1c 100644 --- a/src/queue/src/Contracts/QueueableEntity.php +++ b/src/contracts/src/Queue/QueueableEntity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface QueueableEntity { diff --git a/src/queue/src/Contracts/ShouldBeEncrypted.php b/src/contracts/src/Queue/ShouldBeEncrypted.php similarity index 64% rename from src/queue/src/Contracts/ShouldBeEncrypted.php rename to src/contracts/src/Queue/ShouldBeEncrypted.php index 66f1777d2..54cf3e50b 100644 --- a/src/queue/src/Contracts/ShouldBeEncrypted.php +++ b/src/contracts/src/Queue/ShouldBeEncrypted.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface ShouldBeEncrypted { diff --git a/src/queue/src/Contracts/ShouldBeUnique.php b/src/contracts/src/Queue/ShouldBeUnique.php similarity index 63% rename from src/queue/src/Contracts/ShouldBeUnique.php rename to src/contracts/src/Queue/ShouldBeUnique.php index 96e44ba55..4198c42fe 100644 --- a/src/queue/src/Contracts/ShouldBeUnique.php +++ b/src/contracts/src/Queue/ShouldBeUnique.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface ShouldBeUnique { diff --git a/src/queue/src/Contracts/ShouldBeUniqueUntilProcessing.php b/src/contracts/src/Queue/ShouldBeUniqueUntilProcessing.php similarity index 73% rename from src/queue/src/Contracts/ShouldBeUniqueUntilProcessing.php rename to src/contracts/src/Queue/ShouldBeUniqueUntilProcessing.php index abc0f6ef1..b876f62f4 100644 --- a/src/queue/src/Contracts/ShouldBeUniqueUntilProcessing.php +++ b/src/contracts/src/Queue/ShouldBeUniqueUntilProcessing.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface ShouldBeUniqueUntilProcessing extends ShouldBeUnique { diff --git a/src/queue/src/Contracts/ShouldQueue.php b/src/contracts/src/Queue/ShouldQueue.php similarity index 62% rename from src/queue/src/Contracts/ShouldQueue.php rename to src/contracts/src/Queue/ShouldQueue.php index 5b36d72b2..6c492f13d 100644 --- a/src/queue/src/Contracts/ShouldQueue.php +++ b/src/contracts/src/Queue/ShouldQueue.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface ShouldQueue { diff --git a/src/queue/src/Contracts/ShouldQueueAfterCommit.php b/src/contracts/src/Queue/ShouldQueueAfterCommit.php similarity index 71% rename from src/queue/src/Contracts/ShouldQueueAfterCommit.php rename to src/contracts/src/Queue/ShouldQueueAfterCommit.php index 3f757d106..7882d9ede 100644 --- a/src/queue/src/Contracts/ShouldQueueAfterCommit.php +++ b/src/contracts/src/Queue/ShouldQueueAfterCommit.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Queue\Contracts; +namespace Hypervel\Contracts\Queue; interface ShouldQueueAfterCommit extends ShouldQueue { diff --git a/src/router/src/Contracts/UrlGenerator.php b/src/contracts/src/Router/UrlGenerator.php similarity index 99% rename from src/router/src/Contracts/UrlGenerator.php rename to src/contracts/src/Router/UrlGenerator.php index 9afab98a3..fc02d02f2 100644 --- a/src/router/src/Contracts/UrlGenerator.php +++ b/src/contracts/src/Router/UrlGenerator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Router\Contracts; +namespace Hypervel\Contracts\Router; use BackedEnum; use Closure; diff --git a/src/router/src/Contracts/UrlRoutable.php b/src/contracts/src/Router/UrlRoutable.php similarity index 86% rename from src/router/src/Contracts/UrlRoutable.php rename to src/contracts/src/Router/UrlRoutable.php index b04c3c93c..e01b49653 100644 --- a/src/router/src/Contracts/UrlRoutable.php +++ b/src/contracts/src/Router/UrlRoutable.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Router\Contracts; +namespace Hypervel\Contracts\Router; -use Hyperf\Database\Model\Model; +use Hypervel\Database\Eloquent\Model; interface UrlRoutable { diff --git a/src/session/src/Contracts/Factory.php b/src/contracts/src/Session/Factory.php similarity index 82% rename from src/session/src/Contracts/Factory.php rename to src/contracts/src/Session/Factory.php index 1dd50a0d7..8f866d79d 100644 --- a/src/session/src/Contracts/Factory.php +++ b/src/contracts/src/Session/Factory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Session\Contracts; +namespace Hypervel\Contracts\Session; interface Factory { diff --git a/src/session/src/Contracts/Middleware/AuthenticatesSessions.php b/src/contracts/src/Session/Middleware/AuthenticatesSessions.php similarity index 58% rename from src/session/src/Contracts/Middleware/AuthenticatesSessions.php rename to src/contracts/src/Session/Middleware/AuthenticatesSessions.php index d0625b828..c14a06cb2 100644 --- a/src/session/src/Contracts/Middleware/AuthenticatesSessions.php +++ b/src/contracts/src/Session/Middleware/AuthenticatesSessions.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Session\Contracts\Middleware; +namespace Hypervel\Contracts\Session\Middleware; interface AuthenticatesSessions { diff --git a/src/session/src/Contracts/Session.php b/src/contracts/src/Session/Session.php similarity index 98% rename from src/session/src/Contracts/Session.php rename to src/contracts/src/Session/Session.php index a9fdbc160..ee060735d 100644 --- a/src/session/src/Contracts/Session.php +++ b/src/contracts/src/Session/Session.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Session\Contracts; +namespace Hypervel\Contracts\Session; use SessionHandlerInterface; use UnitEnum; diff --git a/src/contracts/src/Support/Arrayable.php b/src/contracts/src/Support/Arrayable.php new file mode 100755 index 000000000..8581b28e4 --- /dev/null +++ b/src/contracts/src/Support/Arrayable.php @@ -0,0 +1,19 @@ + + */ + public function toArray(): array; +} diff --git a/src/contracts/src/Support/CanBeEscapedWhenCastToString.php b/src/contracts/src/Support/CanBeEscapedWhenCastToString.php new file mode 100644 index 000000000..59f8ed112 --- /dev/null +++ b/src/contracts/src/Support/CanBeEscapedWhenCastToString.php @@ -0,0 +1,15 @@ + [ - HyperfDatabaseFactory::class => DatabaseFactoryInvoker::class, - HyperfMigrationCreator::class => MigrationCreator::class, CompilerInterface::class => CompilerFactory::class, ], - 'listeners' => [ - TransactionListener::class, - ], - 'commands' => [ - InstallCommand::class, - MigrateCommand::class, - FreshCommand::class, - RefreshCommand::class, - ResetCommand::class, - RollbackCommand::class, - StatusCommand::class, - SeedCommand::class, - ], 'annotations' => [ 'scan' => [ 'class_map' => [ - MigrationBaseCommand::class => __DIR__ . '/../class_map/Database/Commands/Migrations/BaseCommand.php', Confirmable::class => __DIR__ . '/../class_map/Command/Concerns/Confirmable.php', Coroutine::class => __DIR__ . '/../class_map/Hyperf/Coroutine/Coroutine.php', ], diff --git a/src/core/src/Context/ApplicationContext.php b/src/core/src/Context/ApplicationContext.php index 45e51f8fd..eceedcd09 100644 --- a/src/core/src/Context/ApplicationContext.php +++ b/src/core/src/Context/ApplicationContext.php @@ -5,7 +5,7 @@ namespace Hypervel\Context; use Hyperf\Context\ApplicationContext as HyperfApplicationContext; -use Hypervel\Container\Contracts\Container as ContainerContract; +use Hypervel\Contracts\Container\Container as ContainerContract; use TypeError; class ApplicationContext extends HyperfApplicationContext diff --git a/src/core/src/Database/Eloquent/Builder.php b/src/core/src/Database/Eloquent/Builder.php deleted file mode 100644 index 0986b4d97..000000000 --- a/src/core/src/Database/Eloquent/Builder.php +++ /dev/null @@ -1,296 +0,0 @@ - - * - * @method TModel make(array $attributes = []) - * @method TModel create(array $attributes = []) - * @method TModel forceCreate(array $attributes = []) - * @method TModel firstOrNew(array $attributes = [], array $values = []) - * @method TModel firstOrCreate(array $attributes = [], array $values = []) - * @method TModel createOrFirst(array $attributes = [], array $values = []) - * @method TModel updateOrCreate(array $attributes, array $values = []) - * @method null|TModel first(mixed $columns = ['*']) - * @method TModel firstOrFail(mixed $columns = ['*']) - * @method TModel sole(mixed $columns = ['*']) - * @method ($id is (array|\Hyperf\Contract\Arrayable) ? \Hypervel\Database\Eloquent\Collection : null|TModel) find(mixed $id, array $columns = ['*']) - * @method ($id is (array|\Hyperf\Contract\Arrayable) ? \Hypervel\Database\Eloquent\Collection : TModel) findOrNew(mixed $id, array $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Collection findMany(array|\Hypervel\Support\Contracts\Arrayable $ids, array $columns = ['*']) - * @method $this where(mixed $column, mixed $operator = null, mixed $value = null, string $boolean = 'and') - * @method $this orWhere(mixed $column, mixed $operator = null, mixed $value = null) - * @method $this with(mixed $relations, mixed ...$args) - * @method $this without(mixed $relations) - * @method $this withWhereHas(string $relation, (\Closure(\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Contracts\Relation<*, *, *>): mixed)|null $callback = null, string $operator = '>=', int $count = 1) - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Collection hydrate(array $items) - * @method \Hypervel\Database\Eloquent\Collection fromQuery(string $query, array $bindings = []) - * @method array getModels(array|string $columns = ['*']) - * @method array eagerLoadRelations(array $models) - * @method \Hypervel\Database\Eloquent\Relations\Contracts\Relation<\Hypervel\Database\Eloquent\Model, TModel, *> getRelation(string $name) - * @method TModel getModel() - * @method bool chunk(int $count, callable(\Hypervel\Database\Eloquent\Collection, int): (bool|void) $callback) - * @method bool chunkById(int $count, callable(\Hypervel\Database\Eloquent\Collection, int): (bool|void) $callback, null|string $column = null, null|string $alias = null) - * @method bool chunkByIdDesc(int $count, callable(\Hypervel\Database\Eloquent\Collection, int): (bool|void) $callback, null|string $column = null, null|string $alias = null) - * @method bool each(callable(TModel, int): (bool|void) $callback, int $count = 1000) - * @method bool eachById(callable(TModel, int): (bool|void) $callback, int $count = 1000, null|string $column = null, null|string $alias = null) - * @method $this whereIn(string $column, mixed $values, string $boolean = 'and', bool $not = false) - */ -class Builder extends BaseBuilder -{ - use QueriesRelationships; - - /** - * Dynamically handle calls into the query instance. - * - * Extends parent to support methods marked with #[Scope] attribute - * in addition to the traditional 'scope' prefix convention. - * - * @param string $method - * @param array $parameters - * @return mixed - */ - public function __call($method, $parameters) - { - if ($method === 'macro') { - $this->localMacros[$parameters[0]] = $parameters[1]; - - return; - } - - if ($method === 'mixin') { - return static::registerMixin($parameters[0], $parameters[1] ?? true); - } - - if ($this->hasMacro($method)) { - array_unshift($parameters, $this); - - return $this->localMacros[$method](...$parameters); - } - - if (static::hasGlobalMacro($method)) { - $macro = static::$macros[$method]; - - if ($macro instanceof Closure) { - return call_user_func_array($macro->bindTo($this, static::class), $parameters); - } - - return call_user_func_array($macro, $parameters); - } - - // Check for named scopes (both 'scope' prefix and #[Scope] attribute) - if ($this->hasNamedScope($method)) { - return $this->callNamedScope($method, $parameters); - } - - if (in_array($method, $this->passthru)) { - return $this->toBase()->{$method}(...$parameters); - } - - $this->query->{$method}(...$parameters); - - return $this; - } - - /** - * Determine if the given model has a named scope. - */ - public function hasNamedScope(string $scope): bool - { - return $this->model && $this->model->hasNamedScope($scope); - } - - /** - * Call the given named scope on the model. - * - * @param array $parameters - */ - protected function callNamedScope(string $scope, array $parameters = []): mixed - { - return $this->callScope(function (...$params) use ($scope) { - return $this->model->callNamedScope($scope, $params); - }, $parameters); - } - - /** - * @return \Hypervel\Support\LazyCollection - */ - public function cursor() - { - return new LazyCollection(function () { - yield from parent::cursor(); - }); - } - - /** - * @return \Hypervel\Support\LazyCollection - */ - public function lazy(int $chunkSize = 1000): LazyCollection - { - return new LazyCollection(function () use ($chunkSize) { - yield from parent::lazy($chunkSize); - }); - } - - /** - * @return \Hypervel\Support\LazyCollection - */ - public function lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): LazyCollection - { - return new LazyCollection(function () use ($chunkSize, $column, $alias) { - yield from parent::lazyById($chunkSize, $column, $alias); - }); - } - - /** - * @return \Hypervel\Support\LazyCollection - */ - public function lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): LazyCollection - { - return new LazyCollection(function () use ($chunkSize, $column, $alias) { - yield from parent::lazyByIdDesc($chunkSize, $column, $alias); - }); - } - - /** - * @template TWhenParameter - * @template TWhenReturnType of static|void - * - * @param Closure(static): TWhenParameter|TWhenParameter $value - * @param Closure(static, TWhenParameter): TWhenReturnType $callback - * @param null|(Closure(static, TWhenParameter): TWhenReturnType) $default - * - * @return (TWhenReturnType is void ? static : TWhenReturnType) - */ - public function when($value = null, ?callable $callback = null, ?callable $default = null) - { - return parent::when($value, $callback, $default); - } - - /** - * @param array|string $column - * @param null|string $key - * @return \Hypervel\Support\Collection - */ - public function pluck($column, $key = null) - { - return new BaseCollection(parent::pluck($column, $key)->all()); - } - - /** - * @template TValue - * - * @param array|(Closure(): TValue)|string $columns - * @param null|(Closure(): TValue) $callback - * @return ( - * $id is (\Hyperf\Contract\Arrayable|array) - * ? \Hypervel\Database\Eloquent\Collection - * : TModel|TValue - * ) - */ - public function findOr(mixed $id, array|Closure|string $columns = ['*'], ?Closure $callback = null): mixed - { - return parent::findOr($id, $columns, $callback); - } - - /** - * @template TValue - * - * @param array|(Closure(): TValue) $columns - * @param null|(Closure(): TValue) $callback - * @return TModel|TValue - */ - public function firstOr($columns = ['*'], ?Closure $callback = null) - { - return parent::firstOr($columns, $callback); - } - - /** - * @param mixed $id - * @param array $columns - * @return ($id is (array|\Hyperf\Contract\Arrayable) ? \Hypervel\Database\Eloquent\Collection : TModel) - * - * @throws \Hypervel\Database\Eloquent\ModelNotFoundException - */ - public function findOrFail($id, $columns = ['*']) - { - try { - return parent::findOrFail($id, $columns); - } catch (BaseModelNotFoundException) { - throw (new ModelNotFoundException())->setModel( - get_class($this->model), - $id - ); - } - } - - /** - * @param array $columns - * @return TModel - * - * @throws \Hypervel\Database\Eloquent\ModelNotFoundException - */ - public function firstOrFail($columns = ['*']) - { - try { - return parent::firstOrFail($columns); - } catch (BaseModelNotFoundException) { - throw (new ModelNotFoundException())->setModel( - get_class($this->model) - ); - } - } - - /** - * @param array|string $columns - * @return TModel - * - * @throws \Hypervel\Database\Eloquent\ModelNotFoundException - */ - public function sole($columns = ['*']) - { - try { - return parent::sole($columns); - } catch (BaseModelNotFoundException) { - throw (new ModelNotFoundException())->setModel( - get_class($this->model) - ); - } - } - - /** - * @template TNewModel of \Hypervel\Database\Eloquent\Model - * - * @param TNewModel $model - * @return static - */ - public function setModel($model) - { - return parent::setModel($model); - } - - /** - * @template TReturn - * - * @param callable(TModel): TReturn $callback - * @param int $count - * @return \Hypervel\Database\Eloquent\Collection - */ - public function chunkMap(callable $callback, $count = 1000): Collection - { - return Collection::make(parent::chunkMap($callback, $count)); - } -} diff --git a/src/core/src/Database/Eloquent/Collection.php b/src/core/src/Database/Eloquent/Collection.php deleted file mode 100644 index 5533fd68e..000000000 --- a/src/core/src/Database/Eloquent/Collection.php +++ /dev/null @@ -1,145 +0,0 @@ - - * - * @method $this load($relations) - * @method $this loadMissing($relations) - * @method $this loadMorph(string $relation, $relations) - * @method $this loadAggregate($relations, string $column, string $function = null) - * @method $this loadCount($relations) - * @method $this loadMax($relations, string $column) - * @method $this loadMin($relations, string $column) - * @method $this loadSum($relations, string $column) - * @method $this loadAvg($relations, string $column) - * @method $this loadMorphCount(string $relation, $relations) - * @method $this makeVisible($attributes) - * @method $this makeHidden($attributes) - * @method $this append($attributes) - * @method $this diff($items) - * @method $this intersect($items) - * @method $this unique((callable(TModel, TKey): mixed)|string|null $key = null, bool $strict = false) - * @method $this only($keys) - * @method $this except($keys) - * @method $this merge($items) - * @method \Hypervel\Database\Eloquent\Collection fresh($with = []) - * @method \Hypervel\Database\Eloquent\Builder toQuery() - * @method array modelKeys() - */ -class Collection extends BaseCollection -{ - use TransformsToResourceCollection; - - /** - * @template TFindDefault - * - * @param mixed $key - * @param TFindDefault $default - * @return ($key is (array|\Hyperf\Contract\Arrayable) ? static : TFindDefault|TModel) - */ - public function find($key, $default = null) - { - return parent::find($key, $default); - } - - /** - * @param null|array|string $value - * @param null|string $key - * @return \Hypervel\Support\Collection - */ - public function pluck($value, $key = null): Enumerable - { - return $this->toBase()->pluck($value, $key); - } - - /** - * @return \Hypervel\Support\Collection - */ - public function keys(): Enumerable - { - return $this->toBase()->keys(); - } - - /** - * @template TZipValue - * - * @param \Hyperf\Contract\Arrayable|iterable ...$items - * @return \Hypervel\Support\Collection> - */ - public function zip($items): Enumerable - { - return $this->toBase()->zip(...func_get_args()); - } - - /** - * @return \Hypervel\Support\Collection - */ - public function collapse(): Enumerable - { - return $this->toBase()->collapse(); - } - - /** - * @param int $depth - * @return \Hypervel\Support\Collection - */ - public function flatten($depth = INF): Enumerable - { - return $this->toBase()->flatten($depth); - } - - /** - * @return \Hypervel\Support\Collection - */ - public function flip(): Enumerable - { - return $this->toBase()->flip(); - } - - /** - * @template TPadValue - * - * @param int $size - * @param TPadValue $value - * @return \Hypervel\Support\Collection - */ - public function pad($size, $value): Enumerable - { - return $this->toBase()->pad($size, $value); - } - - /** - * @template TMapValue - * - * @param callable(TModel, TKey): TMapValue $callback - * @return (TMapValue is \Hypervel\Database\Eloquent\Model ? static : \Hypervel\Support\Collection) - */ - public function map(callable $callback): Enumerable - { - $result = parent::map($callback); - - return $result->contains(function ($item) { - return ! $item instanceof Model; - }) ? $result->toBase() : $result; - } - - /** - * @return SupportCollection - */ - public function toBase() - { - return new SupportCollection($this); - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php deleted file mode 100644 index b6c04518c..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ /dev/null @@ -1,143 +0,0 @@ -getCasts()[$key]; - if ($caster = static::$casterCache[static::class][$castType] ?? null) { - return $caster; - } - - $arguments = []; - - $castClass = $castType; - if (is_string($castClass) && str_contains($castClass, ':')) { - $segments = explode(':', $castClass, 2); - - $castClass = $segments[0]; - $arguments = explode(',', $segments[1]); - } - - if (is_subclass_of($castClass, Castable::class)) { - $castClass = $castClass::castUsing(); - } - - if (is_object($castClass)) { - return static::$casterCache[static::class][$castType] = $castClass; - } - - return static::$casterCache[static::class][$castType] = new $castClass(...$arguments); - } - - /** - * Get the casts array. - */ - public function getCasts(): array - { - if (! is_null($cache = static::$castsCache[static::class] ?? null)) { - return $cache; - } - - if ($this->getIncrementing()) { - return static::$castsCache[static::class] = array_merge([$this->getKeyName() => $this->getKeyType()], $this->casts, $this->casts()); - } - - return static::$castsCache[static::class] = array_merge($this->casts, $this->casts()); - } - - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return []; - } - - /** - * Return a timestamp as DateTime object with time set to 00:00:00. - * - * Uses the Date facade to respect any custom date class configured - * via Date::use() (e.g., CarbonImmutable). - */ - protected function asDate(mixed $value): CarbonInterface - { - return $this->asDateTime($value)->startOfDay(); - } - - /** - * Return a timestamp as DateTime object. - * - * Uses the Date facade to respect any custom date class configured - * via Date::use() (e.g., CarbonImmutable). - */ - protected function asDateTime(mixed $value): CarbonInterface - { - // If this value is already a Carbon instance, we shall just return it as is. - // This prevents us having to re-instantiate a Carbon instance when we know - // it already is one, which wouldn't be fulfilled by the DateTime check. - if ($value instanceof CarbonInterface) { - return Date::instance($value); - } - - // If the value is already a DateTime instance, we will just skip the rest of - // these checks since they will be a waste of time, and hinder performance - // when checking the field. We will just return the DateTime right away. - if ($value instanceof DateTimeInterface) { - return Date::parse( - $value->format('Y-m-d H:i:s.u'), - $value->getTimezone() - ); - } - - // If this value is an integer, we will assume it is a UNIX timestamp's value - // and format a Carbon object from this timestamp. This allows flexibility - // when defining your date fields as they might be UNIX timestamps here. - if (is_numeric($value)) { - return Date::createFromTimestamp($value, date_default_timezone_get()); - } - - // If the value is in simply year, month, day format, we will instantiate the - // Carbon instances from that format. Again, this provides for simple date - // fields on the database, while still supporting Carbonized conversion. - if ($this->isStandardDateFormat($value)) { - return Date::instance(Carbon::createFromFormat('Y-m-d', $value)->startOfDay()); - } - - $format = $this->getDateFormat(); - - // Finally, we will just assume this date is in the format used by default on - // the database connection and use that format to create the Carbon object - // that is returned back out to the developers after we convert it here. - $date = Date::createFromFormat($format, $value); - - return $date ?: Date::parse($value); - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasBootableTraits.php b/src/core/src/Database/Eloquent/Concerns/HasBootableTraits.php deleted file mode 100644 index 5c0fa971e..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasBootableTraits.php +++ /dev/null @@ -1,76 +0,0 @@ - 'boot' . class_basename($trait), - $uses - ); - $conventionalInitMethods = array_map( - static fn (string $trait): string => 'initialize' . class_basename($trait), - $uses - ); - - // Iterate through all methods looking for boot/initialize methods - foreach ((new ReflectionClass($class))->getMethods() as $method) { - $methodName = $method->getName(); - - // Handle boot methods (conventional naming OR #[Boot] attribute) - if ( - ! in_array($methodName, $booted, true) - && $method->isStatic() - && ( - in_array($methodName, $conventionalBootMethods, true) - || $method->getAttributes(Boot::class) !== [] - ) - ) { - $method->invoke(null); - $booted[] = $methodName; - } - - // Handle initialize methods (conventional naming OR #[Initialize] attribute) - if ( - in_array($methodName, $conventionalInitMethods, true) - || $method->getAttributes(Initialize::class) !== [] - ) { - TraitInitializers::$container[$class][] = $methodName; - } - } - - TraitInitializers::$container[$class] = array_unique(TraitInitializers::$container[$class]); - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasCallbacks.php b/src/core/src/Database/Eloquent/Concerns/HasCallbacks.php deleted file mode 100644 index 9cd826e09..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasCallbacks.php +++ /dev/null @@ -1,24 +0,0 @@ -get(ModelListener::class) - ->register(new static(), $event, $callback); /* @phpstan-ignore-line */ - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasGlobalScopes.php b/src/core/src/Database/Eloquent/Concerns/HasGlobalScopes.php deleted file mode 100644 index 59125bdac..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasGlobalScopes.php +++ /dev/null @@ -1,134 +0,0 @@ - trait scopes -> class scopes. - * - * @return array> - */ - public static function resolveGlobalScopeAttributes(): array - { - $reflectionClass = new ReflectionClass(static::class); - - $parentClass = get_parent_class(static::class); - $hasParentWithMethod = $parentClass - && $parentClass !== HyperfModel::class - && method_exists($parentClass, 'resolveGlobalScopeAttributes'); - - // Collect attributes from traits, then from the class itself - $attributes = new Collection(); - - foreach ($reflectionClass->getTraits() as $trait) { - foreach ($trait->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { - $attributes->push($attribute); - } - } - - foreach ($reflectionClass->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { - $attributes->push($attribute); - } - - // Process all collected attributes - $scopes = $attributes - ->map(fn (ReflectionAttribute $attribute) => $attribute->getArguments()) - ->flatten(); - - // Prepend parent's scopes if applicable - return $scopes - ->when($hasParentWithMethod, function (Collection $attrs) use ($parentClass) { - /** @var class-string $parentClass */ - return (new Collection($parentClass::resolveGlobalScopeAttributes())) - ->merge($attrs); - }) - ->all(); - } - - /** - * Register multiple global scopes on the model. - * - * @param array|Closure|Scope> $scopes - */ - public static function addGlobalScopes(array $scopes): void - { - foreach ($scopes as $key => $scope) { - if (is_string($key)) { - static::addGlobalScope($key, $scope); - } else { - static::addGlobalScope($scope); - } - } - } - - /** - * Register a new global scope on the model. - * - * Extends Hyperf's implementation to support scope class-strings. - * - * @param Closure|Scope|string $scope - * @return mixed - * - * @throws InvalidArgumentException - */ - public static function addGlobalScope($scope, ?Closure $implementation = null) - { - if (is_string($scope) && $implementation !== null) { - return GlobalScope::$container[static::class][$scope] = $implementation; - } - - if ($scope instanceof Closure) { - return GlobalScope::$container[static::class][spl_object_hash($scope)] = $scope; - } - - if ($scope instanceof Scope) { - return GlobalScope::$container[static::class][get_class($scope)] = $scope; - } - - // Support class-string for Scope classes (Laravel compatibility) - if (class_exists($scope) && is_subclass_of($scope, Scope::class)) { - return GlobalScope::$container[static::class][$scope] = new $scope(); - } - - throw new InvalidArgumentException( - 'Global scope must be an instance of Closure or Scope, or a class-string of a Scope implementation.' - ); - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasLocalScopes.php b/src/core/src/Database/Eloquent/Concerns/HasLocalScopes.php deleted file mode 100644 index dc1570ca3..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasLocalScopes.php +++ /dev/null @@ -1,56 +0,0 @@ - $parameters - */ - public function callNamedScope(string $scope, array $parameters = []): mixed - { - if (static::isScopeMethodWithAttribute($scope)) { - return $this->{$scope}(...$parameters); - } - - return $this->{'scope' . ucfirst($scope)}(...$parameters); - } - - /** - * Determine if the given method has a #[Scope] attribute. - */ - protected static function isScopeMethodWithAttribute(string $method): bool - { - if (! method_exists(static::class, $method)) { - return false; - } - - return (new ReflectionMethod(static::class, $method)) - ->getAttributes(Scope::class) !== []; - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasObservers.php b/src/core/src/Database/Eloquent/Concerns/HasObservers.php deleted file mode 100644 index 9a5eb89e1..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasObservers.php +++ /dev/null @@ -1,93 +0,0 @@ - trait observers -> class observers. - * - * @return array - */ - public static function resolveObserveAttributes(): array - { - $reflectionClass = new ReflectionClass(static::class); - - $parentClass = get_parent_class(static::class); - $hasParentWithTrait = $parentClass - && $parentClass !== HyperfModel::class - && method_exists($parentClass, 'resolveObserveAttributes'); - - // Collect attributes from traits, then from the class itself - $attributes = new Collection(); - - foreach ($reflectionClass->getTraits() as $trait) { - foreach ($trait->getAttributes(ObservedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { - $attributes->push($attribute); - } - } - - foreach ($reflectionClass->getAttributes(ObservedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { - $attributes->push($attribute); - } - - // Process all collected attributes - $observers = $attributes - ->map(fn (ReflectionAttribute $attribute) => $attribute->getArguments()) - ->flatten(); - - // Prepend parent's observers if applicable - return $observers - ->when($hasParentWithTrait, function (Collection $attrs) use ($parentClass) { - /** @var class-string $parentClass */ - return (new Collection($parentClass::resolveObserveAttributes())) - ->merge($attrs); - }) - ->all(); - } - - /** - * Register observers with the model. - * - * @throws RuntimeException - */ - public static function observe(array|object|string $classes): void - { - $manager = ApplicationContext::getContainer() - ->get(ObserverManager::class); - - foreach (Arr::wrap($classes) as $class) { - $manager->register(static::class, $class); - } - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasRelations.php b/src/core/src/Database/Eloquent/Concerns/HasRelations.php deleted file mode 100644 index 5b41bc0a9..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasRelations.php +++ /dev/null @@ -1,19 +0,0 @@ -unsetRelations(); - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasRelationships.php b/src/core/src/Database/Eloquent/Concerns/HasRelationships.php deleted file mode 100644 index c2294d76c..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasRelationships.php +++ /dev/null @@ -1,354 +0,0 @@ - $related - * @param null|string $foreignKey - * @param null|string $localKey - * @return \Hypervel\Database\Eloquent\Relations\HasMany - */ - public function hasMany($related, $foreignKey = null, $localKey = null) - { - $relation = $this->baseHasMany($related, $foreignKey, $localKey); - - return new HasMany( - $relation->getQuery(), - $relation->getParent(), - $relation->getForeignKeyName(), - $relation->getLocalKeyName() - ); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param class-string $related - * @param null|string $foreignKey - * @param null|string $localKey - * @return \Hypervel\Database\Eloquent\Relations\HasOne - */ - public function hasOne($related, $foreignKey = null, $localKey = null) - { - $relation = $this->baseHasOne($related, $foreignKey, $localKey); - - return new HasOne( - $relation->getQuery(), - $relation->getParent(), - $relation->getForeignKeyName(), - $relation->getLocalKeyName() - ); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param class-string $related - * @param null|string $foreignKey - * @param null|string $ownerKey - * @param null|string $relation - * @return \Hypervel\Database\Eloquent\Relations\BelongsTo - */ - public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null) - { - $relation = $this->baseBelongsTo($related, $foreignKey, $ownerKey, $relation); - - return new BelongsTo( - $relation->getQuery(), - $relation->getChild(), - $relation->getForeignKeyName(), - $relation->getOwnerKeyName(), - $relation->getRelationName() - ); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * @template TPivotModel of \Hypervel\Database\Eloquent\Relations\Pivot - * - * @param class-string $related - * @param null|string $table - * @param null|string $foreignPivotKey - * @param null|string $relatedPivotKey - * @param null|string $parentKey - * @param null|string $relatedKey - * @param null|string $relation - * @return \Hypervel\Database\Eloquent\Relations\BelongsToMany - */ - public function belongsToMany( - $related, - $table = null, - $foreignPivotKey = null, - $relatedPivotKey = null, - $parentKey = null, - $relatedKey = null, - $relation = null - ) { - $relation = $this->baseBelongsToMany($related, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relation); - - return new BelongsToMany( - $relation->getQuery(), - $relation->getParent(), - $relation->getTable(), - $relation->getForeignPivotKeyName(), - $relation->getRelatedPivotKeyName(), - $relation->getParentKeyName(), - $relation->getRelatedKeyName(), - $relation->getRelationName() - ); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param class-string $related - * @param string $name - * @param null|string $type - * @param null|string $id - * @param null|string $localKey - * @return \Hypervel\Database\Eloquent\Relations\MorphMany - */ - public function morphMany($related, $name, $type = null, $id = null, $localKey = null) - { - $relation = $this->baseMorphMany($related, $name, $type, $id, $localKey); - - return new MorphMany( - $relation->getQuery(), - $relation->getParent(), - $relation->getMorphType(), - $relation->getForeignKeyName(), - $relation->getLocalKeyName() - ); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param class-string $related - * @param string $name - * @param null|string $type - * @param null|string $id - * @param null|string $localKey - * @return \Hypervel\Database\Eloquent\Relations\MorphOne - */ - public function morphOne($related, $name, $type = null, $id = null, $localKey = null) - { - $relation = $this->baseMorphOne($related, $name, $type, $id, $localKey); - - return new MorphOne( - $relation->getQuery(), - $relation->getParent(), - $relation->getMorphType(), - $relation->getForeignKeyName(), - $relation->getLocalKeyName() - ); - } - - /** - * @param null|string $name - * @param null|string $type - * @param null|string $id - * @param null|string $ownerKey - * @return \Hypervel\Database\Eloquent\Relations\MorphTo<\Hypervel\Database\Eloquent\Model, $this> - */ - public function morphTo($name = null, $type = null, $id = null, $ownerKey = null) - { - $relation = $this->baseMorphTo($name, $type, $id, $ownerKey); - - return new MorphTo( - $relation->getQuery(), - $relation->getChild(), - $relation->getForeignKeyName(), - $relation->getOwnerKeyName(), - $relation->getMorphType(), - $relation->getRelationName() - ); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * @template TPivotModel of \Hypervel\Database\Eloquent\Relations\MorphPivot - * - * @param class-string $related - * @param string $name - * @param null|string $table - * @param null|string $foreignPivotKey - * @param null|string $relatedPivotKey - * @param null|string $parentKey - * @param null|string $relatedKey - * @param bool $inverse - * @return \Hypervel\Database\Eloquent\Relations\MorphToMany - */ - public function morphToMany( - $related, - $name, - $table = null, - $foreignPivotKey = null, - $relatedPivotKey = null, - $parentKey = null, - $relatedKey = null, - $inverse = false - ) { - $relation = $this->baseMorphToMany($related, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $inverse); - - return new MorphToMany( - $relation->getQuery(), - $relation->getParent(), - $name, - $relation->getTable(), - $relation->getForeignPivotKeyName(), - $relation->getRelatedPivotKeyName(), - $relation->getParentKeyName(), - $relation->getRelatedKeyName(), - $relation->getRelationName(), - $inverse - ); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * @template TThroughModel of \Hypervel\Database\Eloquent\Model - * - * @param class-string $related - * @param class-string $through - * @param null|string $firstKey - * @param null|string $secondKey - * @param null|string $localKey - * @param null|string $secondLocalKey - * @return \Hypervel\Database\Eloquent\Relations\HasManyThrough - */ - public function hasManyThrough($related, $through, $firstKey = null, $secondKey = null, $localKey = null, $secondLocalKey = null) - { - $relation = $this->baseHasManyThrough($related, $through, $firstKey, $secondKey, $localKey, $secondLocalKey); - - // Get the through parent model instance - $throughParent = $relation->getParent(); - - return new HasManyThrough( - $relation->getQuery(), - $this, - $throughParent, - $relation->getFirstKeyName(), - $relation->getForeignKeyName(), - $relation->getLocalKeyName(), - $relation->getSecondLocalKeyName() - ); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * @template TThroughModel of \Hypervel\Database\Eloquent\Model - * - * @param class-string $related - * @param class-string $through - * @param null|string $firstKey - * @param null|string $secondKey - * @param null|string $localKey - * @param null|string $secondLocalKey - * @return \Hypervel\Database\Eloquent\Relations\HasOneThrough - */ - public function hasOneThrough($related, $through, $firstKey = null, $secondKey = null, $localKey = null, $secondLocalKey = null) - { - $relation = $this->baseHasOneThrough($related, $through, $firstKey, $secondKey, $localKey, $secondLocalKey); - - // Get the through parent model instance - $throughParent = $relation->getParent(); - - return new HasOneThrough( - $relation->getQuery(), - $this, - $throughParent, - $relation->getFirstKeyName(), - $relation->getForeignKeyName(), - $relation->getLocalKeyName(), - $relation->getSecondLocalKeyName() - ); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * @template TPivotModel of \Hypervel\Database\Eloquent\Relations\MorphPivot - * - * @param class-string $related - * @param string $name - * @param null|string $table - * @param null|string $foreignPivotKey - * @param null|string $relatedPivotKey - * @param null|string $parentKey - * @param null|string $relatedKey - * @return \Hypervel\Database\Eloquent\Relations\MorphToMany - */ - public function morphedByMany( - $related, - $name, - $table = null, - $foreignPivotKey = null, - $relatedPivotKey = null, - $parentKey = null, - $relatedKey = null - ) { - return $this->morphToMany($related, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, true); - } - - /** - * @return string - */ - protected function guessBelongsToRelation() - { - [$one, $two, $three, $caller] = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4); - - return $caller['function'] ?? $three['function']; - } - - /** - * @return null|string - */ - protected function guessBelongsToManyRelation() - { - $caller = Arr::first(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS), function ($trace) { - return ! in_array( - $trace['function'], - array_merge(static::$overriddenManyMethods, ['guessBelongsToManyRelation']) - ); - }); - - return ! is_null($caller) ? $caller['function'] : null; - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasTimestamps.php b/src/core/src/Database/Eloquent/Concerns/HasTimestamps.php deleted file mode 100644 index d8e166824..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasTimestamps.php +++ /dev/null @@ -1,28 +0,0 @@ -uniqueIds() as $column) { - if (empty($model->{$column})) { - $model->{$column} = $model->newUniqueId(); - } - } - }); - } - - /** - * Generate a new ULID for the model. - */ - public function newUniqueId(): string - { - return strtolower((string) Str::ulid()); - } - - /** - * Get the columns that should receive a unique identifier. - */ - public function uniqueIds(): array - { - return [$this->getKeyName()]; - } - - /** - * Get the auto-incrementing key type. - */ - public function getKeyType(): string - { - if (in_array($this->getKeyName(), $this->uniqueIds())) { - return 'string'; - } - - return $this->keyType; - } - - /** - * Get the value indicating whether the IDs are incrementing. - */ - public function getIncrementing(): bool - { - if (in_array($this->getKeyName(), $this->uniqueIds())) { - return false; - } - - return $this->incrementing; - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/HasUuids.php b/src/core/src/Database/Eloquent/Concerns/HasUuids.php deleted file mode 100644 index f5add0667..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasUuids.php +++ /dev/null @@ -1,67 +0,0 @@ -uniqueIds() as $column) { - if (empty($model->{$column})) { - $model->{$column} = $model->newUniqueId(); - } - } - }); - } - - /** - * Generate a new UUID for the model. - */ - public function newUniqueId(): string - { - return (string) Str::orderedUuid(); - } - - /** - * Get the columns that should receive a unique identifier. - */ - public function uniqueIds(): array - { - return [$this->getKeyName()]; - } - - /** - * Get the auto-incrementing key type. - */ - public function getKeyType(): string - { - if (in_array($this->getKeyName(), $this->uniqueIds())) { - return 'string'; - } - - return $this->keyType; - } - - /** - * Get the value indicating whether the IDs are incrementing. - */ - public function getIncrementing(): bool - { - if (in_array($this->getKeyName(), $this->uniqueIds())) { - return false; - } - - return $this->incrementing; - } -} diff --git a/src/core/src/Database/Eloquent/Concerns/QueriesRelationships.php b/src/core/src/Database/Eloquent/Concerns/QueriesRelationships.php deleted file mode 100644 index 514f5c6ff..000000000 --- a/src/core/src/Database/Eloquent/Concerns/QueriesRelationships.php +++ /dev/null @@ -1,309 +0,0 @@ -|string $relation - * @param string $operator - * @param int $count - * @param string $boolean - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback - * @return $this - */ - public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) - { - return $this->baseHas($relation, $operator, $count, $boolean, $callback); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param RelationContract|string $relation - * @param string $operator - * @param int $count - * @return $this - */ - public function orHas($relation, $operator = '>=', $count = 1) - { - return $this->baseOrHas($relation, $operator, $count); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param RelationContract|string $relation - * @param string $boolean - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback - * @return $this - */ - public function doesntHave($relation, $boolean = 'and', ?Closure $callback = null) - { - return $this->baseDoesntHave($relation, $boolean, $callback); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param RelationContract|string $relation - * @return $this - */ - public function orDoesntHave($relation) - { - return $this->baseOrDoesntHave($relation); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param RelationContract|string $relation - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback - * @param string $operator - * @param int $count - * @return $this - */ - public function whereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) - { - return $this->baseWhereHas($relation, $callback, $operator, $count); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param RelationContract|string $relation - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback - * @param string $operator - * @param int $count - * @return $this - */ - public function orWhereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) - { - return $this->baseOrWhereHas($relation, $callback, $operator, $count); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param RelationContract|string $relation - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback - * @return $this - */ - public function whereDoesntHave($relation, ?Closure $callback = null) - { - return $this->baseWhereDoesntHave($relation, $callback); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param RelationContract|string $relation - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback - * @return $this - */ - public function orWhereDoesntHave($relation, ?Closure $callback = null) - { - return $this->baseOrWhereDoesntHave($relation, $callback); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|RelationContract|string $relation - * @param array|string $types - * @param string $operator - * @param int $count - * @param string $boolean - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback - * @return $this - */ - public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) - { - return $this->baseHasMorph($relation, $types, $operator, $count, $boolean, $callback); - } - - /** - * @param \Hyperf\Database\Model\Relations\MorphTo|\Hyperf\Database\Model\Relations\Relation|string $relation - * @param array|string $types - * @param string $operator - * @param int $count - * @return $this - */ - public function orHasMorph($relation, $types, $operator = '>=', $count = 1): Builder|static - { - return $this->baseOrHasMorph($relation, $types, $operator, $count); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|RelationContract|string $relation - * @param array|string $types - * @param string $boolean - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback - * @return $this - */ - public function doesntHaveMorph($relation, $types, $boolean = 'and', ?Closure $callback = null) - { - return $this->baseDoesntHaveMorph($relation, $types, $boolean, $callback); - } - - /** - * @param \Hyperf\Database\Model\Relations\MorphTo|\Hyperf\Database\Model\Relations\Relation|string $relation - * @param array|string $types - * @return $this - */ - public function orDoesntHaveMorph($relation, $types): Builder|static - { - return $this->baseOrDoesntHaveMorph($relation, $types); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|RelationContract|string $relation - * @param array|string $types - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback - * @param string $operator - * @param int $count - * @return $this - */ - public function whereHasMorph($relation, $types, ?Closure $callback = null, $operator = '>=', $count = 1) - { - return $this->baseWhereHasMorph($relation, $types, $callback, $operator, $count); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|RelationContract|string $relation - * @param array|string $types - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback - * @param string $operator - * @param int $count - * @return $this - */ - public function orWhereHasMorph($relation, $types, ?Closure $callback = null, $operator = '>=', $count = 1) - { - return $this->baseOrWhereHasMorph($relation, $types, $callback, $operator, $count); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|RelationContract|string $relation - * @param array|string $types - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback - * @return $this - */ - public function whereDoesntHaveMorph($relation, $types, ?Closure $callback = null) - { - return $this->baseWhereDoesntHaveMorph($relation, $types, $callback); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|RelationContract|string $relation - * @param array|string $types - * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback - * @return $this - */ - public function orWhereDoesntHaveMorph($relation, $types, ?Closure $callback = null) - { - return $this->baseOrWhereDoesntHaveMorph($relation, $types, $callback); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param RelationContract|string $relation - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hyperf\Database\Query\Expression|string $column - * @param mixed $operator - * @param mixed $value - * @return $this - */ - public function whereRelation($relation, $column, $operator = null, $value = null): Builder|static - { - return $this->baseWhereRelation($relation, $column, $operator, $value); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param RelationContract|string $relation - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hyperf\Database\Query\Expression|string $column - * @param mixed $operator - * @param mixed $value - * @return $this - */ - public function orWhereRelation($relation, $column, $operator = null, $value = null): Builder|static - { - return $this->baseOrWhereRelation($relation, $column, $operator, $value); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|RelationContract|string $relation - * @param array|string $types - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hyperf\Database\Query\Expression|string $column - * @param mixed $operator - * @param mixed $value - * @return $this - */ - public function whereMorphRelation($relation, $types, $column, $operator = null, $value = null): Builder|static - { - return $this->baseWhereMorphRelation($relation, $types, $column, $operator, $value); - } - - /** - * @template TRelatedModel of \Hypervel\Database\Eloquent\Model - * - * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|RelationContract|string $relation - * @param array|string $types - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hyperf\Database\Query\Expression|string $column - * @param mixed $operator - * @param mixed $value - * @return $this - */ - public function orWhereMorphRelation($relation, $types, $column, $operator = null, $value = null): Builder|static - { - return $this->baseOrWhereMorphRelation($relation, $types, $column, $operator, $value); - } -} diff --git a/src/core/src/Database/Eloquent/Factories/BelongsToManyRelationship.php b/src/core/src/Database/Eloquent/Factories/BelongsToManyRelationship.php deleted file mode 100644 index cc6dfc530..000000000 --- a/src/core/src/Database/Eloquent/Factories/BelongsToManyRelationship.php +++ /dev/null @@ -1,51 +0,0 @@ -factory instanceof Factory ? $this->factory->create([], $model) : $this->factory) - ->each(function ($attachable) use ($model) { - $model->{$this->relationship}()->attach( - $attachable, - is_callable($this->pivot) ? call_user_func($this->pivot, $model) : $this->pivot - ); - }); - } - - /** - * Specify the model instances to always use when creating relationships. - */ - public function recycle(Collection $recycle): self - { - if ($this->factory instanceof Factory) { - $this->factory = $this->factory->recycle($recycle); - } - - return $this; - } -} diff --git a/src/core/src/Database/Eloquent/Factories/LegacyFactory.php b/src/core/src/Database/Eloquent/Factories/LegacyFactory.php deleted file mode 100644 index aad1dd5eb..000000000 --- a/src/core/src/Database/Eloquent/Factories/LegacyFactory.php +++ /dev/null @@ -1,118 +0,0 @@ -getConnection(); - - return parent::define($class, $attributes, $name); - } - - /** - * Define a callback to run after making a model. - * - * @param string $class - * @return $this - */ - public function afterMaking($class, callable $callback, ?string $name = null) - { - $name = $name ?: $this->getConnection(); - - return parent::afterMaking($class, $callback, $name); - } - - /** - * Define a callback to run after creating a model. - * - * @param string $class - * @return $this - */ - public function afterCreating($class, callable $callback, ?string $name = null) - { - $name = $name ?: $this->getConnection(); - - return parent::afterCreating($class, $callback, $name); - } - - /** - * Get the raw attribute array for a given model. - * - * @param string $class - */ - public function raw($class, array $attributes = [], ?string $name = null): array - { - $name = $name ?: $this->getConnection(); - - return parent::raw($class, $attributes, $name); - } - - /** - * Create a builder for the given model. - * - * @param string $class - * @return \Hyperf\Database\Model\FactoryBuilder - */ - public function of($class, ?string $name = null) - { - $name = $name ?: $this->getConnection(); - - return parent::of($class, $name) - ->connection($name); - } - - /** - * Load factories from path. - * - * @return $this - */ - public function load(string $path) - { - $factory = $this; - - if (is_dir($path)) { - foreach (Finder::create()->files()->name('*.php')->in($path) as $file) { - $realPath = $file->getRealPath(); - if ($this->isClass($realPath)) { - continue; - } - - require $realPath; - } - } - - return $factory; - } - - protected function isClass(string $file): bool - { - $contents = file_get_contents($file); - if ($contents === false) { - return false; - } - - return preg_match('/^\s*class\s+(\w+)/m', $contents) === 1; - } - - protected function getConnection(): string - { - return ApplicationContext::getContainer() - ->get(ConfigInterface::class) - ->get('database.default'); - } -} diff --git a/src/core/src/Database/Eloquent/Factories/LegacyFactoryInvoker.php b/src/core/src/Database/Eloquent/Factories/LegacyFactoryInvoker.php deleted file mode 100644 index 8fd455b1a..000000000 --- a/src/core/src/Database/Eloquent/Factories/LegacyFactoryInvoker.php +++ /dev/null @@ -1,27 +0,0 @@ -get(ConfigInterface::class); - - $factory = new LegacyFactory( - FakerFactory::create($config->get('app.faker_locale', 'en_US')) - ); - - if (is_dir($path = database_path('factories') ?: '')) { - $factory->load($path); - } - - return $factory; - } -} diff --git a/src/core/src/Database/Eloquent/Model.php b/src/core/src/Database/Eloquent/Model.php deleted file mode 100644 index 40244d4bc..000000000 --- a/src/core/src/Database/Eloquent/Model.php +++ /dev/null @@ -1,326 +0,0 @@ - all(array|string $columns = ['*']) - * @method static \Hypervel\Database\Eloquent\Builder on($connection = null) - * @method static \Hypervel\Database\Eloquent\Builder onWriteConnection() - * @method static \Hypervel\Database\Eloquent\Builder query() - * @method \Hypervel\Database\Eloquent\Builder newQuery() - * @method static \Hypervel\Database\Eloquent\Builder newModelQuery() - * @method static \Hypervel\Database\Eloquent\Builder newQueryWithoutRelationships() - * @method static \Hypervel\Database\Eloquent\Builder newQueryWithoutScopes() - * @method static \Hypervel\Database\Eloquent\Builder newQueryWithoutScope($scope) - * @method static \Hypervel\Database\Eloquent\Builder newQueryForRestoration($ids) - * @method static static make(array $attributes = []) - * @method static static create(array $attributes = []) - * @method static static forceCreate(array $attributes = []) - * @method static static firstOrNew(array $attributes = [], array $values = []) - * @method static static firstOrCreate(array $attributes = [], array $values = []) - * @method static static updateOrCreate(array $attributes, array $values = []) - * @method static null|static first(mixed $columns = ['*']) - * @method static static firstOrFail(mixed $columns = ['*']) - * @method static static sole(mixed $columns = ['*']) - * @method static ($id is (array|\Hyperf\Contract\Arrayable) ? \Hypervel\Database\Eloquent\Collection : null|static) find(mixed $id, array $columns = ['*']) - * @method static ($id is (array|\Hyperf\Contract\Arrayable) ? \Hypervel\Database\Eloquent\Collection : static) findOrNew(mixed $id, array $columns = ['*']) - * @method static ($id is (array|\Hyperf\Contract\Arrayable) ? \Hypervel\Database\Eloquent\Collection : static) findOrFail(mixed $id, array $columns = ['*']) - * @method static \Hypervel\Database\Eloquent\Collection findMany(array|\Hypervel\Support\Contracts\Arrayable $ids, array $columns = ['*']) - * @method static \Hypervel\Database\Eloquent\Builder where(mixed $column, mixed $operator = null, mixed $value = null, string $boolean = 'and') - * @method static \Hypervel\Database\Eloquent\Builder orWhere(mixed $column, mixed $operator = null, mixed $value = null) - * @method static \Hypervel\Database\Eloquent\Builder with(mixed $relations, mixed ...$args) - * @method static \Hypervel\Database\Eloquent\Builder without(mixed $relations) - * @method static \Hypervel\Database\Eloquent\Builder withWhereHas(string $relation, (\Closure(\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Contracts\Relation<*, *, *>): mixed)|null $callback = null, string $operator = '>=', int $count = 1) - * @method static \Hypervel\Database\Eloquent\Builder whereMorphDoesntHaveRelation(mixed $relation, array|string $types, mixed $column, mixed $operator = null, mixed $value = null) - * @method static \Hypervel\Database\Eloquent\Builder orWhereMorphDoesntHaveRelation(mixed $relation, array|string $types, mixed $column, mixed $operator = null, mixed $value = null) - * @method static \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method static \Hypervel\Database\Eloquent\Collection hydrate(array $items) - * @method static \Hypervel\Database\Eloquent\Collection fromQuery(string $query, array $bindings = []) - * @method static array getModels(array|string $columns = ['*']) - * @method static array eagerLoadRelations(array $models) - * @method static \Hypervel\Database\Eloquent\Relations\Contracts\Relation<\Hypervel\Database\Eloquent\Model, static, *> getRelation(string $name) - * @method static static getModel() - * @method static bool chunk(int $count, callable(\Hypervel\Support\Collection, int): (bool|void) $callback) - * @method static bool chunkById(int $count, callable(\Hypervel\Support\Collection, int): (bool|void) $callback, null|string $column = null, null|string $alias = null) - * @method static bool chunkByIdDesc(int $count, callable(\Hypervel\Support\Collection, int): (bool|void) $callback, null|string $column = null, null|string $alias = null) - * @method static bool each(callable(static, int): (bool|void) $callback, int $count = 1000) - * @method static bool eachById(callable(static, int): (bool|void) $callback, int $count = 1000, null|string $column = null, null|string $alias = null) - * @method static \Hypervel\Database\Eloquent\Builder whereIn(string $column, mixed $values, string $boolean = 'and', bool $not = false) - * - * @mixin \Hypervel\Database\Eloquent\Builder - */ -abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChannel -{ - use HasAttributes; - use HasBootableTraits; - use HasCallbacks; - use HasGlobalScopes; - use HasLocalScopes; - use HasObservers; - use HasRelations; - use HasRelationships; - use HasTimestamps; - use TransformsToResource; - - /** - * The resolved builder class names by model. - * - * @var array, class-string>|false> - */ - protected static array $resolvedBuilderClasses = []; - - /** - * The connection name for the model. - * - * Overrides Hyperf's default of 'default' to null. - */ - protected ?string $connection = null; - - /** - * Set the connection associated with the model. - * - * @param null|string|UnitEnum $name - */ - public function setConnection($name): static - { - $value = enum_value($name); - - $this->connection = is_null($value) ? null : $value; - - return $this; - } - - public function resolveRouteBinding($value) - { - return $this->where($this->getRouteKeyName(), $value)->firstOrFail(); - } - - /** - * Create a new Eloquent query builder for the model. - * - * @param \Hypervel\Database\Query\Builder $query - * @return \Hypervel\Database\Eloquent\Builder - */ - public function newModelBuilder($query) - { - $builderClass = static::$resolvedBuilderClasses[static::class] - ??= $this->resolveCustomBuilderClass(); - - if ($builderClass !== false && is_subclass_of($builderClass, Builder::class)) { // @phpstan-ignore function.alreadyNarrowedType (validates attribute returns valid Builder subclass) - // @phpstan-ignore new.static - return new $builderClass($query); - } - - // @phpstan-ignore return.type - return new Builder($query); - } - - /** - * Resolve the custom Eloquent builder class from the model attributes. - * - * @return class-string<\Hypervel\Database\Eloquent\Builder>|false - */ - protected function resolveCustomBuilderClass(): string|false - { - $attributes = (new ReflectionClass(static::class)) - ->getAttributes(UseEloquentBuilder::class); - - if ($attributes === []) { - return false; - } - - // @phpstan-ignore return.type (attribute stores generic Model type, but we know it's compatible with static) - return $attributes[0]->newInstance()->builderClass; - } - - /** - * @param array $models - * @return \Hypervel\Database\Eloquent\Collection - */ - public function newCollection(array $models = []) - { - return new Collection($models); - } - - public function broadcastChannelRoute(): string - { - return str_replace('\\', '.', get_class($this)) . '.{' . Str::camel(class_basename($this)) . '}'; - } - - public function broadcastChannel(): string - { - return str_replace('\\', '.', get_class($this)) . '.' . $this->getKey(); - } - - /** - * @param \Hypervel\Database\Eloquent\Model $parent - * @param string $table - * @param bool $exists - * @param null|string $using - * @return \Hypervel\Database\Eloquent\Relations\Pivot - */ - public function newPivot($parent, array $attributes, $table, $exists, $using = null) - { - return $using ? $using::fromRawAttributes($parent, $attributes, $table, $exists) : Pivot::fromAttributes($parent, $attributes, $table, $exists); - } - - /** - * @return string - */ - protected function guessBelongsToRelation() - { - [$one, $two, $three, $caller] = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4); - - return $caller['function'] ?? $three['function']; // @phpstan-ignore nullCoalesce.offset (defensive backtrace handling) - } - - /** - * Get the event dispatcher instance. - */ - public function getEventDispatcher(): ?EventDispatcherInterface - { - if (Context::get($this->getWithoutEventContextKey())) { - return null; - } - - return parent::getEventDispatcher(); - } - - /** - * Execute a callback without firing any model events for any model type. - */ - public static function withoutEvents(callable $callback): mixed - { - $key = static::getWithoutEventContextKey(); - $depth = Context::get($key) ?? 0; - Context::set($key, $depth + 1); - - try { - return $callback(); - } finally { - $depth = Context::get($key) ?? 1; - if ($depth <= 1) { - Context::destroy($key); - } else { - Context::set($key, $depth - 1); - } - } - } - - /** - * Save the model and all of its relationships without raising any events to the parent model. - */ - public function pushQuietly(): bool - { - return static::withoutEvents(fn () => $this->push()); - } - - /** - * Save the model to the database without raising any events. - */ - public function saveQuietly(array $options = []): bool - { - return static::withoutEvents(fn () => $this->save($options)); - } - - /** - * Update the model in the database without raising any events. - * - * @param array $attributes - * @param array $options - */ - public function updateQuietly(array $attributes = [], array $options = []): bool - { - if (! $this->exists) { - return false; - } - - return $this->fill($attributes)->saveQuietly($options); - } - - /** - * Increment a column's value by a given amount without raising any events. - * @param mixed $amount - */ - public function incrementQuietly(string $column, $amount = 1, array $extra = []): int - { - return static::withoutEvents( - fn () => $this->incrementOrDecrement($column, $amount, $extra, 'increment') - ); - } - - /** - * Decrement a column's value by a given amount without raising any events. - */ - public function decrementQuietly(string $column, float|int $amount = 1, array $extra = []): int - { - return static::withoutEvents( - fn () => $this->incrementOrDecrement($column, $amount, $extra, 'decrement') - ); - } - - /** - * Delete the model from the database without raising any events. - */ - public function deleteQuietly(): bool - { - return static::withoutEvents(fn () => $this->delete()); - } - - /** - * Clone the model into a new, non-existing instance without raising any events. - */ - public function replicateQuietly(?array $except = null): static - { - return static::withoutEvents(fn () => $this->replicate($except)); - } - - /** - * Handle dynamic static method calls into the model. - * - * Checks for methods marked with the #[Scope] attribute before - * falling back to the default behavior. - * - * @param string $method - * @param array $parameters - * @return mixed - */ - public static function __callStatic($method, $parameters) - { - if (static::isScopeMethodWithAttribute($method)) { - return static::query()->{$method}(...$parameters); - } - - return (new static())->{$method}(...$parameters); - } - - protected static function getWithoutEventContextKey(): string - { - return '__database.model.without_events.' . static::class; - } -} diff --git a/src/core/src/Database/Eloquent/ModelListener.php b/src/core/src/Database/Eloquent/ModelListener.php deleted file mode 100644 index 7a22ee590..000000000 --- a/src/core/src/Database/Eloquent/ModelListener.php +++ /dev/null @@ -1,155 +0,0 @@ - Events\Booting::class, - 'booted' => Events\Booted::class, - 'retrieved' => Events\Retrieved::class, - 'creating' => Events\Creating::class, - 'created' => Events\Created::class, - 'updating' => Events\Updating::class, - 'updated' => Events\Updated::class, - 'saving' => Events\Saving::class, - 'saved' => Events\Saved::class, - 'deleting' => Events\Deleting::class, - 'deleted' => Events\Deleted::class, - 'restoring' => Events\Restoring::class, - 'restored' => Events\Restored::class, - 'forceDeleting' => Events\ForceDeleting::class, - 'forceDeleted' => Events\ForceDeleted::class, - ]; - - /** - * Indicates if the manager has been bootstrapped. - */ - protected array $bootstrappedEvents = []; - - /* - * The registered callbacks. - */ - protected array $callbacks = []; - - public function __construct( - protected EventDispatcherInterface $dispatcher - ) { - } - - /** - * Bootstrap the given model events. - */ - protected function bootstrapEvent(string $eventClass): void - { - if ($this->bootstrappedEvents[$eventClass] ?? false) { - return; - } - - /* @phpstan-ignore-next-line */ - $this->dispatcher->listen( - $eventClass, - [$this, 'handleEvent'] - ); - - $this->bootstrappedEvents[$eventClass] = true; - } - - /** - * Register a callback to be executed when a model event is fired. - */ - public function register(Model|string $model, string $event, callable $callback): void - { - if (is_string($model)) { - $this->validateModelClass($model); - } - - $modelClass = $this->getModelClass($model); - if (! $eventClass = (static::MODEL_EVENTS[$event] ?? null)) { - throw new InvalidArgumentException("Event [{$event}] is not a valid Eloquent event."); - } - - $this->bootstrapEvent($eventClass); - - $this->callbacks[$modelClass][$event][] = $callback; - } - - /** - * Remove all of the callbacks for a model event. - */ - public function clear(Model|string $model, ?string $event = null): void - { - $modelClass = $this->getModelClass($model); - if (! $event) { - unset($this->callbacks[$modelClass]); - return; - } - - unset($this->callbacks[$modelClass][$event]); - } - - /** - * Execute callbacks from the given model event. - */ - public function handleEvent(Event $event): void - { - $callbacks = $this->getCallbacks( - $model = $event->getModel(), - $event->getMethod() - ); - - foreach ($callbacks as $callback) { - $callback($model); - } - } - - /** - * Get callbacks by the model and event. - */ - public function getCallbacks(Model|string $model, ?string $event = null): array - { - $modelClass = $this->getModelClass($model); - if ($event) { - return $this->callbacks[$modelClass][$event] ?? []; - } - - return $this->callbacks[$modelClass] ?? []; - } - - /** - * Get all available model events. - */ - public function getModelEvents(): array - { - return static::MODEL_EVENTS; - } - - protected function validateModelClass(string $modelClass): void - { - if (! class_exists($modelClass)) { - throw new InvalidArgumentException('Unable to find model class: ' . $modelClass); - } - - if (! is_subclass_of($modelClass, Model::class)) { - throw new InvalidArgumentException("Model class must extends `{$modelClass}`"); - } - } - - protected function getModelClass(Model|string $model): string - { - return is_string($model) - ? $model - : get_class($model); - } -} diff --git a/src/core/src/Database/Eloquent/ModelNotFoundException.php b/src/core/src/Database/Eloquent/ModelNotFoundException.php deleted file mode 100644 index dd663b468..000000000 --- a/src/core/src/Database/Eloquent/ModelNotFoundException.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - public function getModel(): ?string - { - /* @phpstan-ignore-next-line */ - return parent::getModel(); - } - - /** - * Get the model ids. - * - * @return array - */ - public function getIds(): array - { - return parent::getIds(); - } -} diff --git a/src/core/src/Database/Eloquent/ObserverManager.php b/src/core/src/Database/Eloquent/ObserverManager.php deleted file mode 100644 index 4fd32fe4d..000000000 --- a/src/core/src/Database/Eloquent/ObserverManager.php +++ /dev/null @@ -1,78 +0,0 @@ -resolveObserverClassName($observer); - foreach ($this->listener->getModelEvents() as $event => $eventClass) { - if (! method_exists($observer, $event)) { - continue; - } - - if (isset($this->observers[$modelClass][$event][$observerClass])) { - throw new InvalidArgumentException("Observer [{$observerClass}] is already registered for [{$modelClass}]"); - } - - $observer = $this->container->get($observerClass); - $this->listener->register( - $modelClass, - $event, - [$observer, $event] - ); - $this->observers[$modelClass][$event][$observerClass] = $observer; - } - } - - /** - * Get observers by the model and event. - */ - public function getObservers(string $modelClass, ?string $event = null): array - { - if (is_string($event)) { - return array_values($this->observers[$modelClass][$event] ?? []); - } - - return Arr::flatten($this->observers[$modelClass] ?? []); - } - - /** - * Resolve the observer's class name from an object or string. - * - * @throws InvalidArgumentException - */ - private function resolveObserverClassName(object|string $class): string - { - if (is_object($class)) { - return get_class($class); - } - - if (class_exists($class)) { - return $class; - } - - throw new InvalidArgumentException('Unable to find observer: ' . $class); - } -} diff --git a/src/core/src/Database/Eloquent/Relations/BelongsTo.php b/src/core/src/Database/Eloquent/Relations/BelongsTo.php deleted file mode 100644 index e733c8bc2..000000000 --- a/src/core/src/Database/Eloquent/Relations/BelongsTo.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method TRelatedModel make(array $attributes = []) - * @method TRelatedModel create(array $attributes = []) - * @method TRelatedModel firstOrNew(array $attributes = [], array $values = []) - * @method TRelatedModel firstOrCreate(array $attributes = [], array $values = []) - * @method TRelatedModel updateOrCreate(array $attributes, array $values = []) - * @method null|TRelatedModel first(array|string $columns = ['*']) - * @method TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method TRelatedModel findOrFail(mixed $id, array|string $columns = ['*']) - * @method null|TRelatedModel find(mixed $id, array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Collection findMany(mixed $ids, array|string $columns = ['*']) - * @method TRelatedModel findOrNew(mixed $id, array|string $columns = ['*']) - * @method mixed|TRelatedModel firstOr(\Closure|array|string $columns = ['*'], ?\Closure $callback = null) - * @method TRelatedModel forceCreate(array $attributes) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method \Hypervel\Support\LazyCollection lazy(int $chunkSize = 1000) - * @method \Hypervel\Support\LazyCollection lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Support\LazyCollection lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method null|TRelatedModel getResults() - * @method TChildModel associate(\Hypervel\Database\Eloquent\Model|int|string $model) - * @method TChildModel dissociate() - * @method TChildModel getChild() - */ -class BelongsTo extends BaseBelongsTo implements RelationContract -{ - use WithoutAddConstraints; -} diff --git a/src/core/src/Database/Eloquent/Relations/BelongsToMany.php b/src/core/src/Database/Eloquent/Relations/BelongsToMany.php deleted file mode 100644 index 6cebfa606..000000000 --- a/src/core/src/Database/Eloquent/Relations/BelongsToMany.php +++ /dev/null @@ -1,103 +0,0 @@ -> - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method object{pivot: TPivotModel}&TRelatedModel make(array $attributes = []) - * @method object{pivot: TPivotModel}&TRelatedModel create(array $attributes = []) - * @method object{pivot: TPivotModel}&TRelatedModel firstOrNew(array $attributes = [], array $values = []) - * @method object{pivot: TPivotModel}&TRelatedModel firstOrCreate(array $attributes = [], array $values = []) - * @method object{pivot: TPivotModel}&TRelatedModel updateOrCreate(array $attributes, array $values = []) - * @method object{pivot: TPivotModel}&TRelatedModel createOrFirst(array $attributes = [], array $values = []) - * @method null|(object{pivot: TPivotModel}&TRelatedModel) first(array|string $columns = ['*']) - * @method object{pivot: TPivotModel}&TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method object{pivot: TPivotModel}&TRelatedModel save(\Hypervel\Database\Eloquent\Model $model, array $pivotAttributes = []) - * @method \Hypervel\Database\Eloquent\Collection findMany(mixed $ids, array|string $columns = ['*']) - * @method object{pivot: TPivotModel}&TRelatedModel forceCreate(array $attributes) - * @method array createMany(array $records) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method void attach(mixed $id, array $attributes = [], bool $touch = true) - * @method int detach(mixed $ids = null, bool $touch = true) - * @method array{attached: array, detached: array, updated: array} sync(array|\Hypervel\Support\Collection|\Hypervel\Database\Eloquent\Collection $ids, bool $detaching = true) - * @method array{attached: array, detached: array, updated: array} syncWithoutDetaching(array|\Hypervel\Database\Eloquent\Collection|\Hypervel\Support\Collection $ids) - * @method void toggle(mixed $ids, bool $touch = true) - * @method \Hypervel\Database\Eloquent\Collection newPivotStatement() - * @method \Hypervel\Support\LazyCollection lazy(int $chunkSize = 1000) - * @method \Hypervel\Support\LazyCollection lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Support\LazyCollection lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Database\Eloquent\Collection getResults() - */ -class BelongsToMany extends BaseBelongsToMany implements RelationContract -{ - use InteractsWithPivotTable; - use WithoutAddConstraints; - - /** - * @param mixed $id - * @param array|string $columns - * @return ($id is (array|\Hyperf\Collection\Contracts\Arrayable) ? \Hypervel\Database\Eloquent\Collection : null|(object{pivot: TPivotModel}&TRelatedModel)) - */ - public function find($id, $columns = ['*']) - { - return parent::find($id, $columns); - } - - /** - * @param mixed $id - * @param array|string $columns - * @return ($id is (array|\Hyperf\Collection\Contracts\Arrayable) ? \Hypervel\Database\Eloquent\Collection : object{pivot: TPivotModel}&TRelatedModel) - */ - public function findOrNew($id, $columns = ['*']) - { - return parent::findOrNew($id, $columns); - } - - /** - * @param mixed $id - * @param array|string $columns - * @return ($id is (array|\Hyperf\Collection\Contracts\Arrayable) ? \Hypervel\Database\Eloquent\Collection : object{pivot: TPivotModel}&TRelatedModel) - */ - public function findOrFail($id, $columns = ['*']) - { - return parent::findOrFail($id, $columns); - } - - /** - * @template TValue - * - * @param (Closure(): TValue)|list $columns - * @param null|(Closure(): TValue) $callback - * @return (object{pivot: TPivotModel}&TRelatedModel)|TValue - */ - public function firstOr($columns = ['*'], ?Closure $callback = null) - { - return parent::firstOr($columns, $callback); - } - - /** - * @template TContainer of \Hypervel\Database\Eloquent\Collection|\Hypervel\Support\Collection|array - * - * @param TContainer $models - * @return TContainer - */ - public function saveMany($models, array $pivotAttributes = []) - { - return parent::saveMany($models, $pivotAttributes); - } -} diff --git a/src/core/src/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/src/core/src/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php deleted file mode 100644 index 99dbf2fe7..000000000 --- a/src/core/src/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php +++ /dev/null @@ -1,184 +0,0 @@ -using()`, operations like - * attach/detach/update use model methods (save/delete) instead of raw queries, - * enabling model events (creating, created, deleting, deleted, etc.) to fire. - * - * Without `->using()`, the parent's performant bulk query behavior is preserved. - */ -trait InteractsWithPivotTable -{ - /** - * Attach a model to the parent. - * - * @param mixed $id - * @param bool $touch - */ - public function attach($id, array $attributes = [], $touch = true) - { - if ($this->using) { - $this->attachUsingCustomClass($id, $attributes); - } else { - parent::attach($id, $attributes, $touch); - - return; - } - - if ($touch) { - $this->touchIfTouching(); - } - } - - /** - * Attach a model to the parent using a custom class. - * - * @param mixed $ids - */ - protected function attachUsingCustomClass($ids, array $attributes) - { - $records = $this->formatAttachRecords( - $this->parseIds($ids), - $attributes - ); - - foreach ($records as $record) { - $this->newPivot($record, false)->save(); - } - } - - /** - * Detach models from the relationship. - * - * @param mixed $ids - * @param bool $touch - */ - public function detach($ids = null, $touch = true) - { - if ($this->using) { - $results = $this->detachUsingCustomClass($ids); - } else { - return parent::detach($ids, $touch); - } - - if ($touch) { - $this->touchIfTouching(); - } - - return $results; - } - - /** - * Detach models from the relationship using a custom class. - * - * @param mixed $ids - * @return int - */ - protected function detachUsingCustomClass($ids) - { - $results = 0; - - $pivots = $this->getCurrentlyAttachedPivots($ids); - - foreach ($pivots as $pivot) { - $results += $pivot->delete(); - } - - return $results; - } - - /** - * Update an existing pivot record on the table. - * - * @param mixed $id - * @param bool $touch - */ - public function updateExistingPivot($id, array $attributes, $touch = true) - { - if ($this->using) { - return $this->updateExistingPivotUsingCustomClass($id, $attributes, $touch); - } - - return parent::updateExistingPivot($id, $attributes, $touch); - } - - /** - * Update an existing pivot record on the table via a custom class. - * - * @param mixed $id - * @return int - */ - protected function updateExistingPivotUsingCustomClass($id, array $attributes, bool $touch) - { - $pivot = $this->getCurrentlyAttachedPivots($id)->first(); - - $updated = $pivot ? $pivot->fill($attributes)->isDirty() : false; - - if ($updated) { - $pivot->save(); - } - - if ($touch) { - $this->touchIfTouching(); - } - - return (int) $updated; - } - - /** - * Get the pivot models that are currently attached. - * - * @param mixed $ids - */ - protected function getCurrentlyAttachedPivots($ids = null): Collection - { - $query = $this->newPivotQuery(); - - if ($ids !== null) { - $query->whereIn($this->relatedPivotKey, $this->parseIds($ids)); - } - - return $query->get()->map(function ($record) { - /** @var class-string $class */ - $class = $this->using ?: Pivot::class; - - return $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true) - ->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); - }); - } - - /** - * Create a new pivot model instance. - * - * Overrides parent to include pivotValues in the attributes. - * - * @param bool $exists - */ - public function newPivot(array $attributes = [], $exists = false) - { - $attributes = array_merge( - array_column($this->pivotValues, 'value', 'column'), - $attributes - ); - - /** @var Pivot $pivot */ - $pivot = $this->related->newPivot( - $this->parent, - $attributes, - $this->table, - $exists, - $this->using - ); - - return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); - } -} diff --git a/src/core/src/Database/Eloquent/Relations/Concerns/WithoutAddConstraints.php b/src/core/src/Database/Eloquent/Relations/Concerns/WithoutAddConstraints.php deleted file mode 100644 index 7dcae0f47..000000000 --- a/src/core/src/Database/Eloquent/Relations/Concerns/WithoutAddConstraints.php +++ /dev/null @@ -1,12 +0,0 @@ -> $map - * @param bool $merge - * @return array> - */ - public static function morphMap(?array $map = null, $merge = true); - - /** - * @param string $alias - * @return null|class-string - */ - public static function getMorphedModel($alias); - - /** - * @param class-string $className - */ - public static function getMorphAlias(string $className): string; - - public static function requireMorphMap(bool $requireMorphMap = true): void; - - public static function requiresMorphMap(): bool; - - /** - * @param null|array> $map - * @return array> - */ - public static function enforceMorphMap(?array $map, bool $merge = true): array; - - public function addConstraints(); - - /** - * @param array $models - */ - public function addEagerConstraints(array $models); - - /** - * @param array $models - * @param string $relation - * @return array - */ - public function initRelation(array $models, $relation); - - /** - * @param array $models - * @param Collection $results - * @param string $relation - * @return array - */ - public function match(array $models, Collection $results, $relation); - - /** - * @return TResult - */ - public function getResults(); - - /** - * @return Collection - */ - public function getEager(); - - /** - * @param array|string $columns - * @return Collection - */ - public function get($columns = ['*']); - - public function touch(); - - /** - * @param array $attributes - * @return int - */ - public function rawUpdate(array $attributes = []); - - /** - * @param Builder $query - * @param Builder $parentQuery - * @return Builder - */ - public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery); - - /** - * @param Builder $query - * @param Builder $parentQuery - * @param array|string $columns - * @return Builder - */ - public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']); - - /** - * @return Builder - */ - public function getQuery(); - - /** - * @return \Hyperf\Database\Query\Builder - */ - public function getBaseQuery(); - - /** - * @return TParentModel - */ - public function getParent(); - - /** - * @return string - */ - public function getQualifiedParentKeyName(); - - /** - * @return TRelatedModel - */ - public function getRelated(); - - /** - * @return string - */ - public function createdAt(); - - /** - * @return string - */ - public function updatedAt(); - - /** - * @return string - */ - public function relatedUpdatedAt(); - - /** - * @return string - */ - public function getRelationCountHash(bool $incrementJoinCount = true); -} diff --git a/src/core/src/Database/Eloquent/Relations/HasMany.php b/src/core/src/Database/Eloquent/Relations/HasMany.php deleted file mode 100644 index 3c6b7dd3c..000000000 --- a/src/core/src/Database/Eloquent/Relations/HasMany.php +++ /dev/null @@ -1,65 +0,0 @@ -> - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method null|TRelatedModel first(array|string $columns = ['*']) - * @method TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method mixed|TRelatedModel firstOr(\Closure|array|string $columns = ['*'], ?\Closure $callback = null) - * @method null|TRelatedModel find(mixed $id, array|string $columns = ['*']) - * @method TRelatedModel findOrFail(mixed $id, array|string $columns = ['*']) - * @method TRelatedModel findOrNew(mixed $id, array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Collection findMany(mixed $ids, array|string $columns = ['*']) - * @method TRelatedModel make(array $attributes = []) - * @method TRelatedModel create(array $attributes = []) - * @method TRelatedModel forceCreate(array $attributes) - * @method TRelatedModel firstOrNew(array $attributes = [], array $values = []) - * @method TRelatedModel firstOrCreate(array $attributes = [], array $values = []) - * @method TRelatedModel updateOrCreate(array $attributes, array $values = []) - * @method false|TRelatedModel save(\Hypervel\Database\Eloquent\Model $model) - * @method array saveMany(array|\Hypervel\Support\Collection $models) - * @method \Hypervel\Database\Eloquent\Collection createMany(array $records) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method \Hypervel\Support\LazyCollection lazy(int $chunkSize = 1000) - * @method \Hypervel\Support\LazyCollection lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Support\LazyCollection lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Database\Eloquent\Collection getResults() - */ -class HasMany extends BaseHasMany implements RelationContract -{ - use WithoutAddConstraints; - - /** - * @template TValue - * - * @param array|(Closure(): TValue) $columns - * @param null|(Closure(): TValue) $callback - * @return TRelatedModel|TValue - */ - public function firstOr($columns = ['*'], ?Closure $callback = null) - { - if ($columns instanceof Closure) { - $callback = $columns; - $columns = ['*']; - } - - if (! is_null($model = $this->first($columns))) { - return $model; - } - - return $callback(); - } -} diff --git a/src/core/src/Database/Eloquent/Relations/HasManyThrough.php b/src/core/src/Database/Eloquent/Relations/HasManyThrough.php deleted file mode 100644 index 1278a3c1a..000000000 --- a/src/core/src/Database/Eloquent/Relations/HasManyThrough.php +++ /dev/null @@ -1,34 +0,0 @@ -> - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method null|TRelatedModel first(array|string $columns = ['*']) - * @method TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Collection findMany(mixed $ids, array|string $columns = ['*']) - * @method TRelatedModel findOrFail(mixed $id, array|string $columns = ['*']) - * @method null|TRelatedModel find(mixed $id, array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method \Hypervel\Database\Eloquent\Collection chunk(int $count, callable $callback) - * @method \Hypervel\Support\LazyCollection lazy(int $chunkSize = 1000) - * @method \Hypervel\Support\LazyCollection lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Support\LazyCollection lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Database\Eloquent\Collection getResults() - */ -class HasManyThrough extends BaseHasManyThrough implements RelationContract -{ - use WithoutAddConstraints; -} diff --git a/src/core/src/Database/Eloquent/Relations/HasOne.php b/src/core/src/Database/Eloquent/Relations/HasOne.php deleted file mode 100644 index 99e95c091..000000000 --- a/src/core/src/Database/Eloquent/Relations/HasOne.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method null|TRelatedModel first(array|string $columns = ['*']) - * @method TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method mixed|TRelatedModel firstOr(\Closure|array|string $columns = ['*'], ?\Closure $callback = null) - * @method null|TRelatedModel find(mixed $id, array|string $columns = ['*']) - * @method TRelatedModel findOrFail(mixed $id, array|string $columns = ['*']) - * @method TRelatedModel findOrNew(mixed $id, array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Collection findMany(mixed $ids, array|string $columns = ['*']) - * @method TRelatedModel make(array $attributes = []) - * @method TRelatedModel create(array $attributes = []) - * @method TRelatedModel forceCreate(array $attributes) - * @method TRelatedModel firstOrNew(array $attributes = [], array $values = []) - * @method TRelatedModel firstOrCreate(array $attributes = [], array $values = []) - * @method TRelatedModel updateOrCreate(array $attributes, array $values = []) - * @method false|TRelatedModel save(\Hypervel\Database\Eloquent\Model $model) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method TRelatedModel getRelated() - * @method TParentModel getParent() - * @method \Hypervel\Support\LazyCollection lazy(int $chunkSize = 1000) - * @method \Hypervel\Support\LazyCollection lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Support\LazyCollection lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method null|TRelatedModel getResults() - */ -class HasOne extends BaseHasOne implements RelationContract -{ - use WithoutAddConstraints; -} diff --git a/src/core/src/Database/Eloquent/Relations/HasOneThrough.php b/src/core/src/Database/Eloquent/Relations/HasOneThrough.php deleted file mode 100644 index 3caa0ebf8..000000000 --- a/src/core/src/Database/Eloquent/Relations/HasOneThrough.php +++ /dev/null @@ -1,45 +0,0 @@ - - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method null|TRelatedModel first(array|string $columns = ['*']) - * @method TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method TRelatedModel findOrFail(mixed $id, array|string $columns = ['*']) - * @method ($id is (array|\Hyperf\Collection\Contracts\Arrayable) ? \Hypervel\Database\Eloquent\Collection : null|TRelatedModel) find(mixed $id, array $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method \Hypervel\Support\LazyCollection lazy(int $chunkSize = 1000) - * @method \Hypervel\Support\LazyCollection lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Support\LazyCollection lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method null|TRelatedModel getResults() - */ -class HasOneThrough extends BaseHasOneThrough implements RelationContract -{ - use WithoutAddConstraints; - - /** - * @template TValue - * - * @param (Closure(): TValue)|list $columns - * @param null|(Closure(): TValue) $callback - * @return TRelatedModel|TValue - */ - public function firstOr($columns = ['*'], ?Closure $callback = null) - { - return parent::firstOr($columns, $callback); - } -} diff --git a/src/core/src/Database/Eloquent/Relations/MorphMany.php b/src/core/src/Database/Eloquent/Relations/MorphMany.php deleted file mode 100644 index 5fc3a2988..000000000 --- a/src/core/src/Database/Eloquent/Relations/MorphMany.php +++ /dev/null @@ -1,37 +0,0 @@ -> - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method TRelatedModel make(array $attributes = []) - * @method TRelatedModel create(array $attributes = []) - * @method TRelatedModel firstOrNew(array $attributes = [], array $values = []) - * @method TRelatedModel firstOrCreate(array $attributes = [], array $values = []) - * @method TRelatedModel updateOrCreate(array $attributes, array $values = []) - * @method null|TRelatedModel first(array|string $columns = ['*']) - * @method TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Collection findMany(mixed $ids, array|string $columns = ['*']) - * @method TRelatedModel findOrFail(mixed $id, array|string $columns = ['*']) - * @method null|TRelatedModel find(mixed $id, array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method \Hypervel\Support\LazyCollection lazy(int $chunkSize = 1000) - * @method \Hypervel\Support\LazyCollection lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Support\LazyCollection lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Database\Eloquent\Collection getResults() - */ -class MorphMany extends BaseMorphMany implements RelationContract -{ - use WithoutAddConstraints; -} diff --git a/src/core/src/Database/Eloquent/Relations/MorphOne.php b/src/core/src/Database/Eloquent/Relations/MorphOne.php deleted file mode 100644 index 636b05afb..000000000 --- a/src/core/src/Database/Eloquent/Relations/MorphOne.php +++ /dev/null @@ -1,38 +0,0 @@ - - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method TRelatedModel make(array $attributes = []) - * @method TRelatedModel create(array $attributes = []) - * @method TRelatedModel forceCreate(array $attributes = []) - * @method TRelatedModel firstOrNew(array $attributes = [], array $values = []) - * @method TRelatedModel firstOrCreate(array $attributes = [], array $values = []) - * @method TRelatedModel updateOrCreate(array $attributes, array $values = []) - * @method null|TRelatedModel first(array|string $columns = ['*']) - * @method TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method TRelatedModel findOrFail(mixed $id, array|string $columns = ['*']) - * @method null|TRelatedModel find(mixed $id, array|string $columns = ['*']) - * @method false|TRelatedModel save(\Hypervel\Database\Eloquent\Model $model) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method \Hypervel\Support\LazyCollection lazy(int $chunkSize = 1000) - * @method \Hypervel\Support\LazyCollection lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Support\LazyCollection lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method null|TRelatedModel getResults() - */ -class MorphOne extends BaseMorphOne implements RelationContract -{ - use WithoutAddConstraints; -} diff --git a/src/core/src/Database/Eloquent/Relations/MorphPivot.php b/src/core/src/Database/Eloquent/Relations/MorphPivot.php deleted file mode 100644 index 122fde24d..000000000 --- a/src/core/src/Database/Eloquent/Relations/MorphPivot.php +++ /dev/null @@ -1,73 +0,0 @@ -connection = is_null($value) ? null : $value; - - return $this; - } - - /** - * Delete the pivot model record from the database. - * - * Overrides parent to fire deleting/deleted events even for composite key pivots, - * while maintaining the morph type constraint. - */ - public function delete(): mixed - { - // If pivot has a primary key, use parent's delete which fires events - if (isset($this->attributes[$this->getKeyName()])) { - return parent::delete(); - } - - // For composite key pivots, manually fire events around the raw delete - if ($event = $this->fireModelEvent('deleting')) { - if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) { - return 0; - } - } - - $query = $this->getDeleteQuery(); - - // Add morph type constraint (from Hyperf's MorphPivot::delete()) - $query->where($this->morphType, $this->morphClass); - - $result = $query->delete(); - - $this->exists = false; - - $this->fireModelEvent('deleted'); - - return $result; - } -} diff --git a/src/core/src/Database/Eloquent/Relations/MorphTo.php b/src/core/src/Database/Eloquent/Relations/MorphTo.php deleted file mode 100644 index 747edbbc0..000000000 --- a/src/core/src/Database/Eloquent/Relations/MorphTo.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method TRelatedModel make(array $attributes = []) - * @method TRelatedModel create(array $attributes = []) - * @method TRelatedModel firstOrNew(array $attributes = [], array $values = []) - * @method TRelatedModel firstOrCreate(array $attributes = [], array $values = []) - * @method TRelatedModel updateOrCreate(array $attributes, array $values = []) - * @method null|TRelatedModel first(array|string $columns = ['*']) - * @method TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method TRelatedModel findOrFail(mixed $id, array|string $columns = ['*']) - * @method null|TRelatedModel find(mixed $id, array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method string getMorphType() - * @method TRelatedModel createModelByType(string $type) - * @method null|TRelatedModel getResults() - * @method \Hypervel\Database\Eloquent\Collection getEager() - * @method TChildModel associate(\Hyperf\Database\Model\Model $model) - * @method TChildModel dissociate() - */ -class MorphTo extends BaseMorphTo implements RelationContract -{ - use WithoutAddConstraints; - - /** - * @param string $type - * @return TRelatedModel - */ - public function createModelByType($type) - { - $class = Model::getActualClassNameForMorph($type); - - return new $class(); - } -} diff --git a/src/core/src/Database/Eloquent/Relations/MorphToMany.php b/src/core/src/Database/Eloquent/Relations/MorphToMany.php deleted file mode 100644 index c0ca92a7c..000000000 --- a/src/core/src/Database/Eloquent/Relations/MorphToMany.php +++ /dev/null @@ -1,107 +0,0 @@ -> - * - * @method \Hypervel\Database\Eloquent\Collection get(array|string $columns = ['*']) - * @method object{pivot: TPivotModel}&TRelatedModel make(array $attributes = []) - * @method object{pivot: TPivotModel}&TRelatedModel create(array $attributes = []) - * @method object{pivot: TPivotModel}&TRelatedModel firstOrNew(array $attributes = [], array $values = []) - * @method object{pivot: TPivotModel}&TRelatedModel firstOrCreate(array $attributes = [], array $values = []) - * @method object{pivot: TPivotModel}&TRelatedModel updateOrCreate(array $attributes, array $values = []) - * @method null|(object{pivot: TPivotModel}&TRelatedModel) first(array|string $columns = ['*']) - * @method object{pivot: TPivotModel}&TRelatedModel firstOrFail(array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Collection findMany(mixed $ids, array|string $columns = ['*']) - * @method object{pivot: TPivotModel}&TRelatedModel findOrFail(mixed $id, array|string $columns = ['*']) - * @method null|(object{pivot: TPivotModel}&TRelatedModel) find(mixed $id, array|string $columns = ['*']) - * @method \Hypervel\Database\Eloquent\Builder getQuery() - * @method void attach(mixed $id, array $attributes = [], bool $touch = true) - * @method int detach(mixed $ids = null, bool $touch = true) - * @method void sync(array|\Hypervel\Support\Collection $ids, bool $detaching = true) - * @method void syncWithoutDetaching(array|\Hypervel\Support\Collection $ids) - * @method void toggle(mixed $ids, bool $touch = true) - * @method string getMorphType() - * @method string getMorphClass() - * @method \Hypervel\Support\LazyCollection lazy(int $chunkSize = 1000) - * @method \Hypervel\Support\LazyCollection lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Support\LazyCollection lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null) - * @method \Hypervel\Database\Eloquent\Collection getResults() - */ -class MorphToMany extends BaseMorphToMany implements RelationContract -{ - use InteractsWithPivotTable; - use WithoutAddConstraints; - - /** - * Get the pivot models that are currently attached. - * - * Overrides trait to use MorphPivot and set morph type/class on the pivot models. - * - * @param mixed $ids - */ - protected function getCurrentlyAttachedPivots($ids = null): Collection - { - $query = $this->newPivotQuery(); - - if ($ids !== null) { - $query->whereIn($this->relatedPivotKey, $this->parseIds($ids)); - } - - return $query->get()->map(function ($record) { - /** @var class-string $class */ - $class = $this->using ?: MorphPivot::class; - - $pivot = $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true) - ->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); - - if ($pivot instanceof MorphPivot) { - $pivot->setMorphType($this->morphType) - ->setMorphClass($this->morphClass); - } - - return $pivot; - }); - } - - /** - * Create a new pivot model instance. - * - * Overrides parent to include pivotValues and set morph type/class. - * - * @param bool $exists - * @return TPivotModel - */ - public function newPivot(array $attributes = [], $exists = false) - { - $attributes = array_merge( - array_column($this->pivotValues, 'value', 'column'), - $attributes - ); - - $using = $this->using; - - $pivot = $using ? $using::fromRawAttributes($this->parent, $attributes, $this->table, $exists) - : MorphPivot::fromAttributes($this->parent, $attributes, $this->table, $exists); - - $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey) - ->setMorphType($this->morphType) - ->setMorphClass($this->morphClass); - - return $pivot; - } -} diff --git a/src/core/src/Database/Eloquent/Relations/Pivot.php b/src/core/src/Database/Eloquent/Relations/Pivot.php deleted file mode 100644 index 533087f2f..000000000 --- a/src/core/src/Database/Eloquent/Relations/Pivot.php +++ /dev/null @@ -1,67 +0,0 @@ -connection = is_null($value) ? null : $value; - - return $this; - } - - /** - * Delete the pivot model record from the database. - * - * Overrides parent to fire deleting/deleted events even for composite key pivots. - */ - public function delete(): mixed - { - // If pivot has a primary key, use parent's delete which fires events - if (isset($this->attributes[$this->getKeyName()])) { - return parent::delete(); - } - - // For composite key pivots, manually fire events around the raw delete - if ($event = $this->fireModelEvent('deleting')) { - if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) { - return 0; - } - } - - $result = $this->getDeleteQuery()->delete(); - - $this->exists = false; - - $this->fireModelEvent('deleted'); - - return $result; - } -} diff --git a/src/core/src/Database/Eloquent/Relations/Relation.php b/src/core/src/Database/Eloquent/Relations/Relation.php deleted file mode 100644 index 4e9381eca..000000000 --- a/src/core/src/Database/Eloquent/Relations/Relation.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ -abstract class Relation extends BaseRelation implements RelationContract -{ - /** - * @template TReturn - * - * @param Closure(): TReturn $callback - * @return TReturn - */ - public static function noConstraints($callback) - { - return parent::noConstraints($callback); - } -} diff --git a/src/core/src/Database/Eloquent/SoftDeletes.php b/src/core/src/Database/Eloquent/SoftDeletes.php deleted file mode 100644 index cbdd5fe75..000000000 --- a/src/core/src/Database/Eloquent/SoftDeletes.php +++ /dev/null @@ -1,34 +0,0 @@ - withTrashed(bool $withTrashed = true) - * @method static \Hypervel\Database\Eloquent\Builder onlyTrashed() - * @method static \Hypervel\Database\Eloquent\Builder withoutTrashed() - * @method static static restoreOrCreate(array $attributes = [], array $values = []) - */ -trait SoftDeletes -{ - use HyperfSoftDeletes; - - /** - * Force a hard delete on a soft deleted model without raising any events. - */ - public function forceDeleteQuietly(): bool - { - return static::withoutEvents(fn () => $this->forceDelete()); - } - - /** - * Restore a soft-deleted model instance without raising any events. - */ - public function restoreQuietly(): bool - { - return static::withoutEvents(fn () => $this->restore()); - } -} diff --git a/src/core/src/Database/Migrations/MigrationCreator.php b/src/core/src/Database/Migrations/MigrationCreator.php deleted file mode 100644 index d0f855825..000000000 --- a/src/core/src/Database/Migrations/MigrationCreator.php +++ /dev/null @@ -1,18 +0,0 @@ -, int): mixed $callback, array $columns = ['*']) - * @method bool chunkById(int $count, callable(\Hypervel\Support\Collection, int): mixed $callback, string|null $column = null, string|null $alias = null) - * @method bool chunkByIdDesc(int $count, callable(\Hypervel\Support\Collection, int): mixed $callback, string|null $column = null, string|null $alias = null) - * @method bool each(callable(object, int): mixed $callback, int $count = 1000) - * @method bool eachById(callable(object, int): mixed $callback, int $count = 1000, string|null $column = null, string|null $alias = null) - */ -class Builder extends BaseBuilder -{ - /** - * @template TValue - * - * @param mixed $id - * @param array<\Hyperf\Database\Query\Expression|string>|(Closure(): TValue)|\Hyperf\Database\Query\Expression|string $columns - * @param null|(Closure(): TValue) $callback - * @return object|TValue - */ - public function findOr($id, $columns = ['*'], ?Closure $callback = null) - { - if ($columns instanceof Closure) { - $callback = $columns; - $columns = ['*']; - } - - if (! is_null($record = $this->find($id, $columns))) { - return $record; - } - - return $callback(); - } - - /** - * @return \Hypervel\Support\LazyCollection - */ - public function lazy(int $chunkSize = 1000): LazyCollection - { - return new LazyCollection(function () use ($chunkSize) { - yield from parent::lazy($chunkSize); - }); - } - - /** - * @return \Hypervel\Support\LazyCollection - */ - public function lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): LazyCollection - { - return new LazyCollection(function () use ($chunkSize, $column, $alias) { - yield from parent::lazyById($chunkSize, $column, $alias); - }); - } - - /** - * @return \Hypervel\Support\LazyCollection - */ - public function lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): LazyCollection - { - return new LazyCollection(function () use ($chunkSize, $column, $alias) { - yield from parent::lazyByIdDesc($chunkSize, $column, $alias); - }); - } - - /** - * @template TReturn - * - * @param callable(object): TReturn $callback - * @return \Hypervel\Support\Collection - */ - public function chunkMap(callable $callback, int $count = 1000): BaseCollection - { - return new BaseCollection(parent::chunkMap($callback, $count)->all()); - } - - /** - * @param array|string $column - * @param null|string $key - * @return \Hypervel\Support\Collection - */ - public function pluck($column, $key = null) - { - return new BaseCollection(parent::pluck($column, $key)->all()); - } - - /** - * Cast the given binding value. - * - * Overrides Hyperf's implementation to support UnitEnum (not just BackedEnum). - */ - public function castBinding(mixed $value): mixed - { - if ($value instanceof UnitEnum) { - return enum_value($value); - } - - return $value; - } -} diff --git a/src/core/src/Database/Schema/SchemaProxy.php b/src/core/src/Database/Schema/SchemaProxy.php deleted file mode 100644 index ccbcf5e93..000000000 --- a/src/core/src/Database/Schema/SchemaProxy.php +++ /dev/null @@ -1,36 +0,0 @@ -connection() - ->{$name}(...$arguments); - } - - /** - * Get schema builder with specific connection. - */ - public function connection(?string $name = null): Builder - { - $resolver = ApplicationContext::getContainer() - ->get(ConnectionResolverInterface::class); - - $connection = $resolver->connection( - $name ?: $resolver->getDefaultConnection() - ); - - return $connection->getSchemaBuilder(); - } -} diff --git a/src/core/src/Database/TransactionListener.php b/src/core/src/Database/TransactionListener.php deleted file mode 100644 index ae34b7915..000000000 --- a/src/core/src/Database/TransactionListener.php +++ /dev/null @@ -1,45 +0,0 @@ -container->get(ConnectionResolverInterface::class) - ->connection($event->connectionName) - ->transactionLevel(); - if ($transactionLevel !== 0) { - return; - } - - $this->container->get(TransactionManager::class) - ->runCallbacks(get_class($event)); - } -} diff --git a/src/core/src/Database/TransactionManager.php b/src/core/src/Database/TransactionManager.php deleted file mode 100644 index 0bd93a446..000000000 --- a/src/core/src/Database/TransactionManager.php +++ /dev/null @@ -1,54 +0,0 @@ -getEvent($event)] ?? []; - } - - public function addCallback(callable $callback, ?string $event = null): void - { - Context::override('_db.transactions', function (?array $transactions) use ($event, $callback) { - $transactions = $transactions ?? []; - $transactions[$this->getEvent($event)][] = $callback; - - return $transactions; - }); - } - - public function clearCallbacks(?string $event): void - { - Context::override('_db.transactions', function (?array $transactions) use ($event) { - $transactions = $transactions ?? []; - $transactions[$this->getEvent($event)] = []; - - return $transactions; - }); - } - - public function runCallbacks(?string $event = null): void - { - if (! $callbacks = $this->getCallbacks($this->getEvent($event))) { - return; - } - - foreach ($callbacks as $callback) { - $callback(); - } - - $this->clearCallbacks($event); - } - - public function getEvent(?string $event = null): string - { - return $event ?? TransactionCommitted::class; - } -} diff --git a/src/core/src/View/Middleware/ShareErrorsFromSession.php b/src/core/src/View/Middleware/ShareErrorsFromSession.php index f92a2596f..d1a3bc1f9 100644 --- a/src/core/src/View/Middleware/ShareErrorsFromSession.php +++ b/src/core/src/View/Middleware/ShareErrorsFromSession.php @@ -6,7 +6,7 @@ use Hyperf\ViewEngine\Contract\FactoryInterface; use Hyperf\ViewEngine\ViewErrorBag; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Session\Session as SessionContract; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; diff --git a/src/core/src/View/Middleware/ValidationExceptionHandle.php b/src/core/src/View/Middleware/ValidationExceptionHandle.php index 0dfbf8958..7d9d66098 100644 --- a/src/core/src/View/Middleware/ValidationExceptionHandle.php +++ b/src/core/src/View/Middleware/ValidationExceptionHandle.php @@ -9,7 +9,7 @@ use Hyperf\Support\MessageBag; use Hyperf\ViewEngine\Contract\FactoryInterface; use Hyperf\ViewEngine\ViewErrorBag; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Validation\ValidationException; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; diff --git a/src/coroutine/src/Concurrent.php b/src/coroutine/src/Concurrent.php index 8ad3395dd..81ae6bd49 100644 --- a/src/coroutine/src/Concurrent.php +++ b/src/coroutine/src/Concurrent.php @@ -8,7 +8,7 @@ use Hyperf\Contract\StdoutLoggerInterface; use Hyperf\Coroutine\Concurrent as BaseConcurrent; use Hyperf\ExceptionHandler\Formatter\FormatterInterface; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Throwable; class Concurrent extends BaseConcurrent diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index c869349ca..1a26fe31e 100644 --- a/src/coroutine/src/Coroutine.php +++ b/src/coroutine/src/Coroutine.php @@ -8,7 +8,7 @@ use Hyperf\Contract\StdoutLoggerInterface; use Hyperf\Coroutine\Coroutine as BaseCoroutine; use Hyperf\ExceptionHandler\Formatter\FormatterInterface; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Throwable; class Coroutine extends BaseCoroutine diff --git a/src/database/LICENSE.md b/src/database/LICENSE.md new file mode 100644 index 000000000..fb437bbbe --- /dev/null +++ b/src/database/LICENSE.md @@ -0,0 +1,25 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Copyright (c) Hyperf + +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/database/README.md b/src/database/README.md new file mode 100644 index 000000000..9440d3ce0 --- /dev/null +++ b/src/database/README.md @@ -0,0 +1,4 @@ +Database for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/database) diff --git a/src/database/composer.json b/src/database/composer.json new file mode 100644 index 000000000..79e58f612 --- /dev/null +++ b/src/database/composer.json @@ -0,0 +1,47 @@ +{ + "name": "hypervel/database", + "type": "library", + "description": "The database package for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "hyperf", + "database", + "eloquent", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Database\\": "src/" + } + }, + "require": { + "php": "^8.2", + "hypervel/pool": "~0.3" + }, + "require-dev": { + "fakerphp/faker": "^2.0" + }, + "config": { + "sort-packages": true + }, + "extra": { + "hyperf": { + "config": "Hypervel\\Database\\ConfigProvider" + }, + "branch-alias": { + "dev-main": "0.3-dev" + } + } +} diff --git a/src/database/src/Capsule/Manager.php b/src/database/src/Capsule/Manager.php new file mode 100644 index 000000000..12e0be9ad --- /dev/null +++ b/src/database/src/Capsule/Manager.php @@ -0,0 +1,180 @@ +setupContainer($container ?: new Container(new DefinitionSource([]))); + + // Once we have the container setup, we will setup the default configuration + // options in the container "config" binding. This will make the database + // manager work correctly out of the box without extreme configuration. + $this->setupDefaultConfiguration(); + + $this->setupManager(); + } + + /** + * Setup the default database configuration options. + */ + protected function setupDefaultConfiguration(): void + { + $this->container['config']['database.fetch'] = PDO::FETCH_OBJ; + + $this->container['config']['database.default'] = 'default'; + } + + /** + * Build the database manager instance. + */ + protected function setupManager(): void + { + $factory = new ConnectionFactory($this->container); + + $this->manager = new DatabaseManager($this->container, $factory); + + // Bind a simple non-pooled resolver for Capsule use. + // This is required because DatabaseManager delegates connection + // resolution to ConnectionResolverInterface. + $this->container->instance( + ConnectionResolverInterface::class, + new SimpleConnectionResolver($this->manager) + ); + } + + /** + * Get a connection instance from the global manager. + */ + public static function connection(?string $connection = null): ConnectionInterface + { + return static::$instance->getConnection($connection); + } + + /** + * Get a fluent query builder instance. + * + * @param Closure|Builder|string $table + */ + public static function table($table, ?string $as = null, ?string $connection = null): Builder + { + return static::$instance->connection($connection)->table($table, $as); + } + + /** + * Get a schema builder instance. + */ + public static function schema(?string $connection = null): \Hypervel\Database\Schema\Builder + { + return static::$instance->connection($connection)->getSchemaBuilder(); + } + + /** + * Get a registered connection instance. + */ + public function getConnection(?string $name = null): ConnectionInterface + { + return $this->manager->connection($name); + } + + /** + * Register a connection with the manager. + */ + public function addConnection(array $config, string $name = 'default'): void + { + $connections = $this->container['config']['database.connections']; + + $connections[$name] = $config; + + $this->container['config']['database.connections'] = $connections; + } + + /** + * Bootstrap Eloquent so it is ready for usage. + */ + public function bootEloquent(): void + { + Eloquent::setConnectionResolver($this->manager); + + // If we have an event dispatcher instance, we will go ahead and register it + // with the Eloquent ORM, allowing for model callbacks while creating and + // updating "model" instances; however, it is not necessary to operate. + if ($dispatcher = $this->getEventDispatcher()) { + Eloquent::setEventDispatcher($dispatcher); + } + } + + /** + * Set the fetch mode for the database connections. + */ + public function setFetchMode(int $fetchMode): static + { + $this->container['config']['database.fetch'] = $fetchMode; + + return $this; + } + + /** + * Get the database manager instance. + */ + public function getDatabaseManager(): DatabaseManager + { + return $this->manager; + } + + /** + * Get the current event dispatcher instance. + */ + public function getEventDispatcher(): ?Dispatcher + { + if ($this->container->bound('events')) { + return $this->container['events']; + } + + return null; + } + + /** + * Set the event dispatcher instance to be used by connections. + */ + public function setEventDispatcher(Dispatcher $dispatcher): void + { + $this->container->instance('events', $dispatcher); + } + + /** + * Dynamically pass methods to the default connection. + */ + public static function __callStatic(string $method, array $parameters): mixed + { + return static::connection()->$method(...$parameters); + } +} diff --git a/src/database/src/ClassMorphViolationException.php b/src/database/src/ClassMorphViolationException.php new file mode 100644 index 000000000..f892d8f42 --- /dev/null +++ b/src/database/src/ClassMorphViolationException.php @@ -0,0 +1,27 @@ +model = $class; + } +} diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php new file mode 100644 index 000000000..4d79c7c92 --- /dev/null +++ b/src/database/src/Concerns/BuildsQueries.php @@ -0,0 +1,554 @@ +, int): mixed $callback + */ + public function chunk(int $count, callable $callback): bool + { + $this->enforceOrderBy(); + + $skip = $this->getOffset(); + $remaining = $this->getLimit(); + + $page = 1; + + do { + $offset = (($page - 1) * $count) + (int) $skip; + + $limit = is_null($remaining) ? $count : min($count, $remaining); + + if ($limit == 0) { + break; + } + + $results = $this->offset($offset)->limit($limit)->get(); + + $countResults = $results->count(); + + if ($countResults == 0) { + break; + } + + if (! is_null($remaining)) { + $remaining = max($remaining - $countResults, 0); + } + + // @phpstan-ignore argument.type (Eloquent hydrates to TModel, not stdClass) + if ($callback($results, $page) === false) { + return false; + } + + unset($results); + + ++$page; + } while ($countResults == $count); + + return true; + } + + /** + * Run a map over each item while chunking. + * + * @template TReturn + * + * @param callable(TValue): TReturn $callback + * @return \Hypervel\Support\Collection + */ + public function chunkMap(callable $callback, int $count = 1000): Collection + { + $collection = new Collection(); + + $this->chunk($count, function ($items) use ($collection, $callback) { + $items->each(function ($item) use ($collection, $callback) { + $collection->push($callback($item)); + }); + }); + + return $collection; + } + + /** + * Execute a callback over each item while chunking. + * + * @param callable(TValue, int): mixed $callback + */ + public function each(callable $callback, int $count = 1000): bool + { + return $this->chunk($count, function ($results) use ($callback) { + foreach ($results as $key => $value) { + if ($callback($value, $key) === false) { + return false; + } + } + }); + } + + /** + * Chunk the results of a query by comparing IDs. + * + * @param callable(\Hypervel\Support\Collection, int): mixed $callback + */ + public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool + { + return $this->orderedChunkById($count, $callback, $column, $alias); + } + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param callable(\Hypervel\Support\Collection, int): mixed $callback + */ + public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool + { + return $this->orderedChunkById($count, $callback, $column, $alias, descending: true); + } + + /** + * Chunk the results of a query by comparing IDs in a given order. + * + * @param callable(\Hypervel\Support\Collection, int): mixed $callback + */ + public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, bool $descending = false): bool + { + $column ??= $this->defaultKeyName(); + $alias ??= $column; + $lastId = null; + $skip = $this->getOffset(); + $remaining = $this->getLimit(); + + $page = 1; + + do { + $clone = clone $this; + + if ($skip && $page > 1) { + $clone->offset(0); + } + + $limit = is_null($remaining) ? $count : min($count, $remaining); + + if ($limit == 0) { + break; + } + + // We'll execute the query for the given page and get the results. If there are + // no results we can just break and return from here. When there are results + // we will call the callback with the current chunk of these results here. + if ($descending) { + $results = $clone->forPageBeforeId($limit, $lastId, $column)->get(); + } else { + $results = $clone->forPageAfterId($limit, $lastId, $column)->get(); + } + + $countResults = $results->count(); + + if ($countResults == 0) { + break; + } + + if (! is_null($remaining)) { + $remaining = max($remaining - $countResults, 0); + } + + // 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; + } + + $lastId = data_get($results->last(), $alias); + + if ($lastId === null) { + throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result."); + } + + unset($results); + + ++$page; + } while ($countResults == $count); + + return true; + } + + /** + * Execute a callback over each item while chunking by ID. + * + * @param callable(TValue, int): mixed $callback + */ + public function eachById(callable $callback, int $count = 1000, ?string $column = null, ?string $alias = null): bool + { + return $this->chunkById($count, function ($results, $page) use ($callback, $count) { + foreach ($results as $key => $value) { + if ($callback($value, (($page - 1) * $count) + $key) === false) { + return false; + } + } + }, $column, $alias); + } + + /** + * Query lazily, by chunks of the given size. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazy(int $chunkSize = 1000): LazyCollection + { + if ($chunkSize < 1) { + throw new InvalidArgumentException('The chunk size should be at least 1'); + } + + $this->enforceOrderBy(); + + return new LazyCollection(function () use ($chunkSize) { + $page = 1; + + while (true) { + $results = $this->forPage($page++, $chunkSize)->get(); + + foreach ($results as $result) { + yield $result; + } + + if ($results->count() < $chunkSize) { + return; + } + } + }); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): LazyCollection + { + return $this->orderedLazyById($chunkSize, $column, $alias); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs in descending order. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): LazyCollection + { + return $this->orderedLazyById($chunkSize, $column, $alias, true); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs in a given order. + * + * @return \Hypervel\Support\LazyCollection + */ + protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null, bool $descending = false): LazyCollection + { + if ($chunkSize < 1) { + throw new InvalidArgumentException('The chunk size should be at least 1'); + } + + $column ??= $this->defaultKeyName(); + + $alias ??= $column; + + return new LazyCollection(function () use ($chunkSize, $column, $alias, $descending) { + $lastId = null; + + while (true) { + $clone = clone $this; + + if ($descending) { + $results = $clone->forPageBeforeId($chunkSize, $lastId, $column)->get(); + } else { + $results = $clone->forPageAfterId($chunkSize, $lastId, $column)->get(); + } + + foreach ($results as $result) { + yield $result; + } + + if ($results->count() < $chunkSize) { + return; + } + + $lastId = $results->last()->{$alias}; + + if ($lastId === null) { + throw new RuntimeException("The lazyById operation was aborted because the [{$alias}] column is not present in the query result."); + } + } + }); + } + + /** + * Execute the query and get the first result. + * + * @return null|TValue + */ + public function first(array|string $columns = ['*']) + { + // @phpstan-ignore return.type (Eloquent hydrates to TModel, not stdClass) + return $this->limit(1)->get($columns)->first(); + } + + /** + * Execute the query and get the first result or throw an exception. + * + * @return TValue + * + * @throws \Hypervel\Database\RecordNotFoundException + */ + public function firstOrFail(array|string $columns = ['*'], ?string $message = null) + { + if (! is_null($result = $this->first($columns))) { + return $result; + } + + throw new RecordNotFoundException($message ?: 'No record found for the given query.'); + } + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @return TValue + * + * @throws \Hypervel\Database\RecordsNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function sole(array|string $columns = ['*']) + { + $result = $this->limit(2)->get($columns); + + $count = $result->count(); + + if ($count === 0) { + throw new RecordsNotFoundException(); + } + + if ($count > 1) { + throw new MultipleRecordsFoundException($count); + } + + // @phpstan-ignore return.type (Eloquent hydrates to TModel, not stdClass) + return $result->first(); + } + + /** + * Paginate the given query using a cursor paginator. + * + * @return \Hypervel\Contracts\Pagination\CursorPaginator + */ + protected function paginateUsingCursor(int $perPage, array|string $columns = ['*'], string $cursorName = 'cursor', Cursor|string|null $cursor = null) + { + if (! $cursor instanceof Cursor) { + $cursor = is_string($cursor) + ? Cursor::fromEncoded($cursor) + : CursorPaginator::resolveCurrentCursor($cursorName, $cursor); + } + + $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); + + if (! is_null($cursor)) { + // Reset the union bindings so we can add the cursor where in the correct position... + $this->setBindings([], 'union'); + + $addCursorConditions = function (self $builder, $previousColumn, $originalColumn, $i) use (&$addCursorConditions, $cursor, $orders) { + $unionBuilders = $builder->getUnionBuilders(); + + if (! is_null($previousColumn)) { + $originalColumn ??= $this->getOriginalColumnNameForCursorPagination($this, $previousColumn); + + $builder->where( + Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn, + '=', + $cursor->parameter($previousColumn) + ); + + $unionBuilders->each(function ($unionBuilder) use ($previousColumn, $cursor) { + $unionBuilder->where( + $this->getOriginalColumnNameForCursorPagination($unionBuilder, $previousColumn), + '=', + $cursor->parameter($previousColumn) + ); + + $this->addBinding($unionBuilder->getRawBindings()['where'], 'union'); + }); + } + + $builder->where(function (self $secondBuilder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) { + ['column' => $column, 'direction' => $direction] = $orders[$i]; + + $originalColumn = $this->getOriginalColumnNameForCursorPagination($this, $column); + + $secondBuilder->where( + Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn, + $direction === 'asc' ? '>' : '<', + $cursor->parameter($column) + ); + + if ($i < $orders->count() - 1) { + $secondBuilder->orWhere(function (self $thirdBuilder) use ($addCursorConditions, $column, $originalColumn, $i) { + $addCursorConditions($thirdBuilder, $column, $originalColumn, $i + 1); + }); + } + + $unionBuilders->each(function ($unionBuilder) use ($column, $direction, $cursor, $i, $orders, $addCursorConditions) { + $unionWheres = $unionBuilder->getRawBindings()['where']; + + $originalColumn = $this->getOriginalColumnNameForCursorPagination($unionBuilder, $column); + $unionBuilder->where(function ($unionBuilder) use ($column, $direction, $cursor, $i, $orders, $addCursorConditions, $originalColumn, $unionWheres) { + $unionBuilder->where( + $originalColumn, + $direction === 'asc' ? '>' : '<', + $cursor->parameter($column) + ); + + if ($i < $orders->count() - 1) { + $unionBuilder->orWhere(function (self $fourthBuilder) use ($addCursorConditions, $column, $originalColumn, $i) { + $addCursorConditions($fourthBuilder, $column, $originalColumn, $i + 1); + }); + } + + $this->addBinding($unionWheres, 'union'); + $this->addBinding($unionBuilder->getRawBindings()['where'], 'union'); + }); + }); + }); + }; + + $addCursorConditions($this, null, null, 0); + } + + $this->limit($perPage + 1); + + return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [ + 'path' => Paginator::resolveCurrentPath(), + 'cursorName' => $cursorName, + 'parameters' => $orders->pluck('column')->toArray(), + ]); + } + + /** + * Get the original column name of the given column, without any aliasing. + * + * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*> $builder + */ + protected function getOriginalColumnNameForCursorPagination(\Hypervel\Database\Query\Builder|Builder $builder, string $parameter): string + { + $columns = $builder instanceof Builder ? $builder->getQuery()->getColumns() : $builder->getColumns(); + + foreach ($columns as $column) { + if (($position = strripos($column, ' as ')) !== false) { + $original = substr($column, 0, $position); + + $alias = substr($column, $position + 4); + + if ($parameter === $alias || $builder->getGrammar()->wrap($parameter) === $alias) { + return $original; + } + } + } + + return $parameter; + } + + /** + * Create a new length-aware paginator instance. + */ + protected function paginator(Collection $items, int $total, int $perPage, int $currentPage, array $options): LengthAwarePaginator + { + return Container::getInstance()->makeWith(LengthAwarePaginator::class, compact( + 'items', + 'total', + 'perPage', + 'currentPage', + 'options' + )); + } + + /** + * Create a new simple paginator instance. + */ + protected function simplePaginator(Collection $items, int $perPage, int $currentPage, array $options): Paginator + { + return Container::getInstance()->makeWith(Paginator::class, compact( + 'items', + 'perPage', + 'currentPage', + 'options' + )); + } + + /** + * Create a new cursor paginator instance. + */ + protected function cursorPaginator(Collection $items, int $perPage, ?Cursor $cursor, array $options): CursorPaginator + { + return Container::getInstance()->makeWith(CursorPaginator::class, compact( + 'items', + 'perPage', + 'cursor', + 'options' + )); + } + + /** + * Pass the query to a given callback and then return it. + * + * @param callable($this): mixed $callback + * @return $this + */ + public function tap(callable $callback): static + { + $callback($this); + + return $this; + } + + /** + * Pass the query to a given callback and return the result. + * + * @template TReturn + * + * @param (callable($this): TReturn) $callback + * @return (TReturn is null|void ? $this : TReturn) + */ + public function pipe(callable $callback) + { + return $callback($this) ?? $this; + } +} diff --git a/src/database/src/Concerns/BuildsWhereDateClauses.php b/src/database/src/Concerns/BuildsWhereDateClauses.php new file mode 100644 index 000000000..f3702a2a6 --- /dev/null +++ b/src/database/src/Concerns/BuildsWhereDateClauses.php @@ -0,0 +1,226 @@ +wherePastOrFuture($columns, '<', 'and'); + } + + /** + * Add a where clause to determine if a "date" column is in the past or now to the query. + * + * @return $this + */ + public function whereNowOrPast(array|string $columns): static + { + return $this->wherePastOrFuture($columns, '<=', 'and'); + } + + /** + * Add an "or where" clause to determine if a "date" column is in the past to the query. + * + * @return $this + */ + public function orWherePast(array|string $columns): static + { + return $this->wherePastOrFuture($columns, '<', 'or'); + } + + /** + * Add a where clause to determine if a "date" column is in the past or now to the query. + * + * @return $this + */ + public function orWhereNowOrPast(array|string $columns): static + { + return $this->wherePastOrFuture($columns, '<=', 'or'); + } + + /** + * Add a where clause to determine if a "date" column is in the future to the query. + * + * @return $this + */ + public function whereFuture(array|string $columns): static + { + return $this->wherePastOrFuture($columns, '>', 'and'); + } + + /** + * Add a where clause to determine if a "date" column is in the future or now to the query. + * + * @return $this + */ + public function whereNowOrFuture(array|string $columns): static + { + return $this->wherePastOrFuture($columns, '>=', 'and'); + } + + /** + * Add an "or where" clause to determine if a "date" column is in the future to the query. + * + * @return $this + */ + public function orWhereFuture(array|string $columns): static + { + return $this->wherePastOrFuture($columns, '>', 'or'); + } + + /** + * Add an "or where" clause to determine if a "date" column is in the future or now to the query. + * + * @return $this + */ + public function orWhereNowOrFuture(array|string $columns): static + { + return $this->wherePastOrFuture($columns, '>=', 'or'); + } + + /** + * Add an "where" clause to determine if a "date" column is in the past or future. + * + * @return $this + */ + protected function wherePastOrFuture(array|string $columns, string $operator, string $boolean): static + { + $type = 'Basic'; + $value = Carbon::now(); + + foreach (Arr::wrap($columns) as $column) { + $this->wheres[] = compact('type', 'column', 'boolean', 'operator', 'value'); + + $this->addBinding($value); + } + + return $this; + } + + /** + * Add a "where date" clause to determine if a "date" column is today to the query. + * + * @return $this + */ + public function whereToday(array|string $columns, string $boolean = 'and'): static + { + return $this->whereTodayBeforeOrAfter($columns, '=', $boolean); + } + + /** + * Add a "where date" clause to determine if a "date" column is before today. + * + * @return $this + */ + public function whereBeforeToday(array|string $columns): static + { + return $this->whereTodayBeforeOrAfter($columns, '<', 'and'); + } + + /** + * Add a "where date" clause to determine if a "date" column is today or before to the query. + * + * @return $this + */ + public function whereTodayOrBefore(array|string $columns): static + { + return $this->whereTodayBeforeOrAfter($columns, '<=', 'and'); + } + + /** + * Add a "where date" clause to determine if a "date" column is after today. + * + * @return $this + */ + public function whereAfterToday(array|string $columns): static + { + return $this->whereTodayBeforeOrAfter($columns, '>', 'and'); + } + + /** + * Add a "where date" clause to determine if a "date" column is today or after to the query. + * + * @return $this + */ + public function whereTodayOrAfter(array|string $columns): static + { + return $this->whereTodayBeforeOrAfter($columns, '>=', 'and'); + } + + /** + * Add an "or where date" clause to determine if a "date" column is today to the query. + * + * @return $this + */ + public function orWhereToday(array|string $columns): static + { + return $this->whereToday($columns, 'or'); + } + + /** + * Add an "or where date" clause to determine if a "date" column is before today. + * + * @return $this + */ + public function orWhereBeforeToday(array|string $columns): static + { + return $this->whereTodayBeforeOrAfter($columns, '<', 'or'); + } + + /** + * Add an "or where date" clause to determine if a "date" column is today or before to the query. + * + * @return $this + */ + public function orWhereTodayOrBefore(array|string $columns): static + { + return $this->whereTodayBeforeOrAfter($columns, '<=', 'or'); + } + + /** + * Add an "or where date" clause to determine if a "date" column is after today. + * + * @return $this + */ + public function orWhereAfterToday(array|string $columns): static + { + return $this->whereTodayBeforeOrAfter($columns, '>', 'or'); + } + + /** + * Add an "or where date" clause to determine if a "date" column is today or after to the query. + * + * @return $this + */ + public function orWhereTodayOrAfter(array|string $columns): static + { + return $this->whereTodayBeforeOrAfter($columns, '>=', 'or'); + } + + /** + * Add a "where date" clause to determine if a "date" column is today or after to the query. + * + * @return $this + */ + protected function whereTodayBeforeOrAfter(array|string $columns, string $operator, string $boolean): static + { + $value = Carbon::today()->format('Y-m-d'); + + foreach (Arr::wrap($columns) as $column) { + $this->addDateBasedWhere('Date', $column, $operator, $value, $boolean); + } + + return $this; + } +} diff --git a/src/database/src/Concerns/CompilesJsonPaths.php b/src/database/src/Concerns/CompilesJsonPaths.php new file mode 100644 index 000000000..e6779b5fd --- /dev/null +++ b/src/database/src/Concerns/CompilesJsonPaths.php @@ -0,0 +1,57 @@ +', $column, 2); + + $field = $this->wrap($parts[0]); + + $path = count($parts) > 1 ? ', ' . $this->wrapJsonPath($parts[1], '->') : ''; + + return [$field, $path]; + } + + /** + * Wrap the given JSON path. + */ + protected function wrapJsonPath(string $value, string $delimiter = '->'): string + { + $value = preg_replace("/([\\\\]+)?\\'/", "''", $value); + + $jsonPath = (new Collection(explode($delimiter, $value))) + ->map(fn ($segment) => $this->wrapJsonPathSegment($segment)) + ->join('.'); + + return "'$" . (str_starts_with($jsonPath, '[') ? '' : '.') . $jsonPath . "'"; + } + + /** + * Wrap the given JSON path segment. + */ + protected function wrapJsonPathSegment(string $segment): string + { + if (preg_match('/(\[[^\]]+\])+$/', $segment, $parts)) { + $key = Str::beforeLast($segment, $parts[0]); + + if (! empty($key)) { + return '"' . $key . '"' . $parts[0]; + } + + return $parts[0]; + } + + return '"' . $segment . '"'; + } +} diff --git a/src/database/src/Concerns/ExplainsQueries.php b/src/database/src/Concerns/ExplainsQueries.php new file mode 100644 index 000000000..839c886fd --- /dev/null +++ b/src/database/src/Concerns/ExplainsQueries.php @@ -0,0 +1,24 @@ +toSql(); + + $bindings = $this->getBindings(); + + $explanation = $this->getConnection()->select('EXPLAIN ' . $sql, $bindings); + + return new Collection($explanation); + } +} diff --git a/src/database/src/Concerns/ManagesTransactions.php b/src/database/src/Concerns/ManagesTransactions.php new file mode 100644 index 000000000..d35068506 --- /dev/null +++ b/src/database/src/Concerns/ManagesTransactions.php @@ -0,0 +1,354 @@ +beginTransaction(); + + // We'll simply execute the given callback within a try / catch block and if we + // catch any exception we can rollback this transaction so that none of this + // gets actually persisted to a database or stored in a permanent fashion. + try { + $callbackResult = $callback($this); + } + + // If we catch an exception we'll rollback this transaction and try again if we + // are not out of attempts. If we are out of attempts we will just throw the + // exception back out, and let the developer handle an uncaught exception. + catch (Throwable $e) { + $this->handleTransactionException( + $e, + $currentAttempt, + $attempts + ); + + continue; + } + + $levelBeingCommitted = $this->transactions; + + try { + if ($this->transactions == 1) { + $this->fireConnectionEvent('committing'); + $this->getPdo()->commit(); + } + + $this->transactions = max(0, $this->transactions - 1); + } catch (Throwable $e) { + $this->handleCommitTransactionException( + $e, + $currentAttempt, + $attempts + ); + + continue; + } + + $this->transactionsManager?->commit( + $this->getName(), + $levelBeingCommitted, + $this->transactions + ); + + $this->fireConnectionEvent('committed'); + + return $callbackResult; + } + + // This should never be reached - exception handlers throw on final attempt + throw new LogicException('Transaction loop completed without returning or throwing.'); + } + + /** + * Handle an exception encountered when running a transacted statement. + * + * @throws Throwable + */ + protected function handleTransactionException(Throwable $e, int $currentAttempt, int $maxAttempts): void + { + // On a deadlock, MySQL rolls back the entire transaction so we can't just + // retry the query. We have to throw this exception all the way out and + // let the developer handle it in another way. We will decrement too. + if ($this->causedByConcurrencyError($e) + && $this->transactions > 1) { + --$this->transactions; + + $this->transactionsManager?->rollback( + $this->getName(), + $this->transactions + ); + + throw new DeadlockException($e->getMessage(), is_int($e->getCode()) ? $e->getCode() : 0, $e); + } + + // If there was an exception we will rollback this transaction and then we + // can check if we have exceeded the maximum attempt count for this and + // if we haven't we will return and try this query again in our loop. + $this->rollBack(); + + if ($this->causedByConcurrencyError($e) + && $currentAttempt < $maxAttempts) { + return; + } + + throw $e; + } + + /** + * Start a new database transaction. + * + * @throws Throwable + */ + public function beginTransaction(): void + { + foreach ($this->beforeStartingTransaction as $callback) { + $callback($this); + } + + $this->createTransaction(); + + ++$this->transactions; + + $this->transactionsManager?->begin( + $this->getName(), + $this->transactions + ); + + $this->fireConnectionEvent('beganTransaction'); + } + + /** + * Create a transaction within the database. + * + * @throws Throwable + */ + protected function createTransaction(): void + { + if ($this->transactions == 0) { + $this->reconnectIfMissingConnection(); + + try { + $this->executeBeginTransactionStatement(); + } catch (Throwable $e) { + $this->handleBeginTransactionException($e); + } + } elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) { + $this->createSavepoint(); + } + } + + /** + * Create a save point within the database. + * + * @throws Throwable + */ + protected function createSavepoint(): void + { + $this->getPdo()->exec( + $this->queryGrammar->compileSavepoint('trans' . ($this->transactions + 1)) + ); + } + + /** + * Handle an exception from a transaction beginning. + * + * @throws Throwable + */ + protected function handleBeginTransactionException(Throwable $e): void + { + if ($this->causedByLostConnection($e)) { + $this->reconnect(); + + $this->executeBeginTransactionStatement(); + } else { + throw $e; + } + } + + /** + * Commit the active database transaction. + * + * @throws Throwable + */ + public function commit(): void + { + if ($this->transactionLevel() == 1) { + $this->fireConnectionEvent('committing'); + $this->getPdo()->commit(); + } + + [$levelBeingCommitted, $this->transactions] = [ + $this->transactions, + max(0, $this->transactions - 1), + ]; + + $this->transactionsManager?->commit( + $this->getName(), + $levelBeingCommitted, + $this->transactions + ); + + $this->fireConnectionEvent('committed'); + } + + /** + * Handle an exception encountered when committing a transaction. + * + * @throws Throwable + */ + protected function handleCommitTransactionException(Throwable $e, int $currentAttempt, int $maxAttempts): void + { + $this->transactions = max(0, $this->transactions - 1); + + if ($this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) { + return; + } + + if ($this->causedByLostConnection($e)) { + $this->transactions = 0; + } + + throw $e; + } + + /** + * Rollback the active database transaction. + * + * @throws Throwable + */ + public function rollBack(?int $toLevel = null): void + { + // We allow developers to rollback to a certain transaction level. We will verify + // that this given transaction level is valid before attempting to rollback to + // that level. If it's not we will just return out and not attempt anything. + $toLevel = is_null($toLevel) + ? $this->transactions - 1 + : $toLevel; + + if ($toLevel < 0 || $toLevel >= $this->transactions) { + return; + } + + // Next, we will actually perform this rollback within this database and fire the + // rollback event. We will also set the current transaction level to the given + // level that was passed into this method so it will be right from here out. + try { + $this->performRollBack($toLevel); + } catch (Throwable $e) { + $this->handleRollBackException($e); + } + + $this->transactions = $toLevel; + + $this->transactionsManager?->rollback( + $this->getName(), + $this->transactions + ); + + $this->fireConnectionEvent('rollingBack'); + } + + /** + * Perform a rollback within the database. + * + * @throws Throwable + */ + protected function performRollBack(int $toLevel): void + { + if ($toLevel == 0) { + $pdo = $this->getPdo(); + + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + } elseif ($this->queryGrammar->supportsSavepoints()) { + $this->getPdo()->exec( + $this->queryGrammar->compileSavepointRollBack('trans' . ($toLevel + 1)) + ); + } + } + + /** + * Handle an exception from a rollback. + * + * @throws Throwable + */ + protected function handleRollBackException(Throwable $e): void + { + if ($this->causedByLostConnection($e)) { + $this->transactions = 0; + + $this->transactionsManager?->rollback( + $this->getName(), + $this->transactions + ); + } + + throw $e; + } + + /** + * Get the number of active transactions. + */ + public function transactionLevel(): int + { + return $this->transactions; + } + + /** + * Execute the callback after a transaction commits. + * + * @throws RuntimeException + */ + public function afterCommit(callable $callback): void + { + if ($this->transactionsManager) { + $this->transactionsManager->addCallback($callback); + + return; + } + + throw new RuntimeException('Transactions Manager has not been set.'); + } + + /** + * Execute the callback after a transaction rolls back. + * + * @throws RuntimeException + */ + public function afterRollBack(callable $callback): void + { + if ($this->transactionsManager) { + $this->transactionsManager->addCallbackForRollback($callback); + + return; + } + + throw new RuntimeException('Transactions Manager has not been set.'); + } +} diff --git a/src/database/src/Concerns/ParsesSearchPath.php b/src/database/src/Concerns/ParsesSearchPath.php new file mode 100644 index 000000000..680813f7b --- /dev/null +++ b/src/database/src/Concerns/ParsesSearchPath.php @@ -0,0 +1,24 @@ +getCode() === 40001 || $e->getCode() === '40001')) { + return true; + } + + $message = $e->getMessage(); + + return Str::contains($message, [ + 'Deadlock found when trying to get lock', + 'deadlock detected', + 'The database file is locked', + 'database is locked', + 'database table is locked', + 'A table in the database is locked', + 'has been chosen as the deadlock victim', + 'Lock wait timeout exceeded; try restarting transaction', + 'WSREP detected deadlock/conflict and aborted the transaction. Try restarting the transaction', + 'Record has changed since last read in table', + ]); + } +} diff --git a/src/database/src/ConfigProvider.php b/src/database/src/ConfigProvider.php new file mode 100644 index 000000000..0e80acf4c --- /dev/null +++ b/src/database/src/ConfigProvider.php @@ -0,0 +1,51 @@ + [ + ConnectionResolverInterface::class => ConnectionResolver::class, + MigrationRepositoryInterface::class => DatabaseMigrationRepositoryFactory::class, + ], + 'listeners' => [ + RegisterConnectionResolverListener::class, + RegisterSQLiteConnectionListener::class, + UnsetContextInTaskWorkerListener::class, + ], + 'commands' => [ + FreshCommand::class, + InstallCommand::class, + MakeMigrationCommand::class, + MigrateCommand::class, + RefreshCommand::class, + ResetCommand::class, + RollbackCommand::class, + SeedCommand::class, + StatusCommand::class, + WipeCommand::class, + ], + ]; + } +} diff --git a/src/database/src/ConfigurationUrlParser.php b/src/database/src/ConfigurationUrlParser.php new file mode 100644 index 000000000..15966ac96 --- /dev/null +++ b/src/database/src/ConfigurationUrlParser.php @@ -0,0 +1,11 @@ + + */ + protected static array $resolvers = []; + + /** + * The last retrieved PDO read / write type. + * + * @var null|'read'|'write' + */ + protected ?string $latestPdoTypeRetrieved = null; + + /** + * Create a new database connection instance. + */ + public function __construct(PDO|Closure $pdo, string $database = '', string $tablePrefix = '', array $config = []) + { + $this->pdo = $pdo; + + // First we will setup the default properties. We keep track of the DB + // name we are connected to since it is needed when some reflective + // type commands are run such as checking whether a table exists. + $this->database = $database; + + $this->tablePrefix = $tablePrefix; + + $this->config = $config; + + // We need to initialize a query grammar and the query post processors + // which are both very important parts of the database abstractions + // so we initialize these to their default values while starting. + $this->useDefaultQueryGrammar(); + + $this->useDefaultPostProcessor(); + } + + /** + * Set the query grammar to the default implementation. + */ + public function useDefaultQueryGrammar(): void + { + $this->queryGrammar = $this->getDefaultQueryGrammar(); + } + + /** + * Get the default query grammar instance. + */ + protected function getDefaultQueryGrammar(): QueryGrammar + { + return new QueryGrammar($this); + } + + /** + * Set the schema grammar to the default implementation. + */ + public function useDefaultSchemaGrammar(): void + { + $this->schemaGrammar = $this->getDefaultSchemaGrammar(); + } + + /** + * Get the default schema grammar instance. + */ + protected function getDefaultSchemaGrammar(): ?Schema\Grammars\Grammar + { + return null; + } + + /** + * Set the query post processor to the default implementation. + */ + public function useDefaultPostProcessor(): void + { + $this->postProcessor = $this->getDefaultPostProcessor(); + } + + /** + * Get the default post processor instance. + */ + protected function getDefaultPostProcessor(): Processor + { + return new Processor(); + } + + /** + * Get a schema builder instance for the connection. + */ + public function getSchemaBuilder(): SchemaBuilder + { + if (is_null($this->schemaGrammar)) { + $this->useDefaultSchemaGrammar(); + } + + return new SchemaBuilder($this); + } + + /** + * Get the schema state for the connection. + * + * @throws RuntimeException + */ + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null): Schema\SchemaState + { + throw new RuntimeException('This database driver does not support schema state.'); + } + + /** + * Begin a fluent query against a database table. + */ + public function table(Closure|QueryBuilder|UnitEnum|string $table, ?string $as = null): QueryBuilder + { + return $this->query()->from(enum_value($table), $as); + } + + /** + * Get a new query builder instance. + */ + public function query(): QueryBuilder + { + return new QueryBuilder( + $this, + $this->getQueryGrammar(), + $this->getPostProcessor() + ); + } + + /** + * Run a select statement and return a single result. + */ + public function selectOne(string $query, array $bindings = [], bool $useReadPdo = true): mixed + { + $records = $this->select($query, $bindings, $useReadPdo); + + return array_shift($records); + } + + /** + * Run a select statement and return the first column of the first row. + * + * @throws \Hypervel\Database\MultipleColumnsSelectedException + */ + public function scalar(string $query, array $bindings = [], bool $useReadPdo = true): mixed + { + $record = $this->selectOne($query, $bindings, $useReadPdo); + + if (is_null($record)) { + return null; + } + + $record = (array) $record; + + if (count($record) > 1) { + throw new MultipleColumnsSelectedException(); + } + + return Arr::first($record); + } + + /** + * Run a select statement against the database. + */ + public function selectFromWriteConnection(string $query, array $bindings = []): array + { + return $this->select($query, $bindings, false); + } + + /** + * Run a select statement against the database. + */ + public function select(string $query, array $bindings = [], bool $useReadPdo = true): array + { + return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { + if ($this->pretending()) { + return []; + } + + // For select statements, we'll simply execute the query and return an array + // of the database result set. Each element in the array will be a single + // row from the database table, and will either be an array or objects. + $statement = $this->prepared( + $this->getPdoForSelect($useReadPdo)->prepare($query) + ); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $statement->execute(); + + return $statement->fetchAll(); + }); + } + + /** + * Run a select statement against the database and returns all of the result sets. + */ + public function selectResultSets(string $query, array $bindings = [], bool $useReadPdo = true): array + { + return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { + if ($this->pretending()) { + return []; + } + + $statement = $this->prepared( + $this->getPdoForSelect($useReadPdo)->prepare($query) + ); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $statement->execute(); + + $sets = []; + + do { + $sets[] = $statement->fetchAll(); + } while ($statement->nextRowset()); + + return $sets; + }); + } + + /** + * Run a select statement against the database and returns a generator. + * + * @return Generator + */ + public function cursor(string $query, array $bindings = [], bool $useReadPdo = true): Generator + { + $statement = $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { + if ($this->pretending()) { + return []; + } + + // First we will create a statement for the query. Then, we will set the fetch + // mode and prepare the bindings for the query. Once that's done we will be + // ready to execute the query against the database and return the cursor. + $statement = $this->prepared($this->getPdoForSelect($useReadPdo) + ->prepare($query)); + + $this->bindValues( + $statement, + $this->prepareBindings($bindings) + ); + + // Next, we'll execute the query against the database and return the statement + // so we can return the cursor. The cursor will use a PHP generator to give + // back one row at a time without using a bunch of memory to render them. + $statement->execute(); + + return $statement; + }); + + while ($record = $statement->fetch()) { + yield $record; + } + } + + /** + * Configure the PDO prepared statement. + */ + protected function prepared(PDOStatement $statement): PDOStatement + { + $statement->setFetchMode($this->fetchMode); + + $this->event(new StatementPrepared($this, $statement)); + + return $statement; + } + + /** + * Get the PDO connection to use for a select query. + */ + protected function getPdoForSelect(bool $useReadPdo = true): PDO + { + return $useReadPdo ? $this->getReadPdo() : $this->getPdo(); + } + + /** + * Run an insert statement against the database. + */ + public function insert(string $query, array $bindings = []): bool + { + return $this->statement($query, $bindings); + } + + /** + * Run an update statement against the database. + */ + public function update(string $query, array $bindings = []): int + { + return $this->affectingStatement($query, $bindings); + } + + /** + * Run a delete statement against the database. + */ + public function delete(string $query, array $bindings = []): int + { + return $this->affectingStatement($query, $bindings); + } + + /** + * Execute an SQL statement and return the boolean result. + */ + public function statement(string $query, array $bindings = []): bool + { + return $this->run($query, $bindings, function ($query, $bindings) { + if ($this->pretending()) { + return true; + } + + $statement = $this->getPdo()->prepare($query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $this->recordsHaveBeenModified(); + + return $statement->execute(); + }); + } + + /** + * Run an SQL statement and get the number of rows affected. + */ + public function affectingStatement(string $query, array $bindings = []): int + { + return $this->run($query, $bindings, function ($query, $bindings) { + if ($this->pretending()) { + return 0; + } + + // For update or delete statements, we want to get the number of rows affected + // by the statement and return that back to the developer. We'll first need + // to execute the statement and then we'll use PDO to fetch the affected. + $statement = $this->getPdo()->prepare($query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $statement->execute(); + + $this->recordsHaveBeenModified( + ($count = $statement->rowCount()) > 0 + ); + + return $count; + }); + } + + /** + * Run a raw, unprepared query against the PDO connection. + */ + public function unprepared(string $query): bool + { + return $this->run($query, [], function ($query) { + if ($this->pretending()) { + return true; + } + + $this->recordsHaveBeenModified( + $change = $this->getPdo()->exec($query) !== false + ); + + return $change; + }); + } + + /** + * Get the number of open connections for the database. + */ + public function threadCount(): ?int + { + $query = $this->getQueryGrammar()->compileThreadCount(); + + return $query ? $this->scalar($query) : null; + } + + /** + * Execute the given callback in "dry run" mode. + * + * @param (Closure(\Hypervel\Database\Connection): mixed) $callback + * @return array{query: string, bindings: array, time: null|float}[] + */ + public function pretend(Closure $callback): array + { + return $this->withFreshQueryLog(function () use ($callback) { + $this->pretending = true; + + try { + // Basically to make the database connection "pretend", we will just return + // the default values for all the query methods, then we will return an + // array of queries that were "executed" within the Closure callback. + $callback($this); + + return $this->queryLog; + } finally { + $this->pretending = false; + } + }); + } + + /** + * Execute the given callback without "pretending". + */ + public function withoutPretending(Closure $callback): mixed + { + if (! $this->pretending) { + return $callback(); + } + + $this->pretending = false; + + try { + return $callback(); + } finally { + $this->pretending = true; + } + } + + /** + * Execute the given callback in "dry run" mode. + * + * @return array{query: string, bindings: array, time: null|float}[] + */ + protected function withFreshQueryLog(Closure $callback): array + { + $loggingQueries = $this->loggingQueries; + + // First we will back up the value of the logging queries property and then + // we'll be ready to run callbacks. This query log will also get cleared + // so we will have a new log of all the queries that are executed now. + $this->enableQueryLog(); + + $this->queryLog = []; + + // Now we'll execute this callback and capture the result. Once it has been + // executed we will restore the value of query logging and give back the + // value of the callback so the original callers can have the results. + $result = $callback(); + + $this->loggingQueries = $loggingQueries; + + return $result; + } + + /** + * Bind values to their parameters in the given statement. + */ + public function bindValues(PDOStatement $statement, array $bindings): void + { + foreach ($bindings as $key => $value) { + $statement->bindValue( + is_string($key) ? $key : $key + 1, + $value, + match (true) { + is_int($value) => PDO::PARAM_INT, + is_resource($value) => PDO::PARAM_LOB, + default => PDO::PARAM_STR + }, + ); + } + } + + /** + * Prepare the query bindings for execution. + */ + public function prepareBindings(array $bindings): array + { + $grammar = $this->getQueryGrammar(); + + foreach ($bindings as $key => $value) { + // We need to transform all instances of DateTimeInterface into the actual + // date string. Each query grammar maintains its own date string format + // so we'll just ask the grammar for the format to get from the date. + if ($value instanceof DateTimeInterface) { + $bindings[$key] = $value->format($grammar->getDateFormat()); + } elseif (is_bool($value)) { + $bindings[$key] = (int) $value; + } + } + + return $bindings; + } + + /** + * Run a SQL statement and log its execution context. + * + * @throws QueryException + */ + protected function run(string $query, array $bindings, Closure $callback): mixed + { + foreach ($this->beforeExecutingCallbacks as $beforeExecutingCallback) { + $beforeExecutingCallback($query, $bindings, $this); + } + + $this->reconnectIfMissingConnection(); + + $start = microtime(true); + + // Here we will run this query. If an exception occurs we'll determine if it was + // caused by a connection that has been lost. If that is the cause, we'll try + // to re-establish connection and re-run the query with a fresh connection. + try { + $result = $this->runQueryCallback($query, $bindings, $callback); + } catch (QueryException $e) { + $result = $this->handleQueryException( + $e, + $query, + $bindings, + $callback + ); + } + + // Once we have run the query we will calculate the time that it took to run and + // then log the query, bindings, and execution time so we will report them on + // the event that the developer needs them. We'll log time in milliseconds. + $this->logQuery( + $query, + $bindings, + $this->getElapsedTime($start) + ); + + return $result; + } + + /** + * Run a SQL statement. + * + * @throws QueryException + */ + protected function runQueryCallback(string $query, array $bindings, Closure $callback): mixed + { + // To execute the statement, we'll simply call the callback, which will actually + // run the SQL against the PDO connection. Then we can calculate the time it + // took to execute and log the query SQL, bindings and time in our memory. + try { + return $callback($query, $bindings); + } + + // If an exception occurs when attempting to run a query, we'll format the error + // message to include the bindings with SQL, which will make this exception a + // lot more helpful to the developer instead of just the database's errors. + catch (Exception $e) { + ++$this->errorCount; + + $exceptionType = $this->isUniqueConstraintError($e) + ? UniqueConstraintViolationException::class + : QueryException::class; + + throw new $exceptionType( + $this->getNameWithReadWriteType(), + $query, + $this->prepareBindings($bindings), + $e, + $this->getConnectionDetails(), + $this->latestReadWriteTypeUsed(), + ); + } + } + + /** + * Determine if the given database exception was caused by a unique constraint violation. + */ + protected function isUniqueConstraintError(Exception $exception): bool + { + return false; + } + + /** + * Log a query in the connection's query log. + */ + public function logQuery(string $query, array $bindings, ?float $time = null): void + { + $this->totalQueryDuration += $time ?? 0.0; + + $readWriteType = $this->latestReadWriteTypeUsed(); + + $this->event(new QueryExecuted($query, $bindings, $time, $this, $readWriteType)); + + $query = $this->pretending === true + ? $this->queryGrammar->substituteBindingsIntoRawSql($query, $bindings) + : $query; + + if ($this->loggingQueries) { + $this->queryLog[] = compact('query', 'bindings', 'time', 'readWriteType'); + } + } + + /** + * Get the elapsed time in milliseconds since a given starting point. + */ + protected function getElapsedTime(float $start): float + { + return round((microtime(true) - $start) * 1000, 2); + } + + /** + * Register a callback to be invoked when the connection queries for longer than a given amount of time. + */ + public function whenQueryingForLongerThan(DateTimeInterface|CarbonInterval|float|int $threshold, callable $handler): void + { + $threshold = $threshold instanceof DateTimeInterface + ? $this->secondsUntil($threshold) * 1000 + : $threshold; + + $threshold = $threshold instanceof CarbonInterval + ? $threshold->totalMilliseconds + : $threshold; + + $this->queryDurationHandlers[] = [ + 'has_run' => false, + 'handler' => $handler, + ]; + + $key = count($this->queryDurationHandlers) - 1; + + $this->listen(function ($event) use ($threshold, $handler, $key) { + if (! $this->queryDurationHandlers[$key]['has_run'] && $this->totalQueryDuration() > $threshold) { + $handler($this, $event); + + $this->queryDurationHandlers[$key]['has_run'] = true; + } + }); + } + + /** + * Allow all the query duration handlers to run again, even if they have already run. + */ + public function allowQueryDurationHandlersToRunAgain(): void + { + foreach ($this->queryDurationHandlers as $key => $queryDurationHandler) { + $this->queryDurationHandlers[$key]['has_run'] = false; + } + } + + /** + * Get the duration of all run queries in milliseconds. + */ + public function totalQueryDuration(): float + { + return $this->totalQueryDuration; + } + + /** + * Reset the duration of all run queries. + */ + public function resetTotalQueryDuration(): void + { + $this->totalQueryDuration = 0.0; + } + + /** + * Handle a query exception. + * + * @throws QueryException + */ + protected function handleQueryException(QueryException $e, string $query, array $bindings, Closure $callback): mixed + { + if ($this->transactions >= 1) { + throw $e; + } + + return $this->tryAgainIfCausedByLostConnection( + $e, + $query, + $bindings, + $callback + ); + } + + /** + * Handle a query exception that occurred during query execution. + * + * @throws QueryException + */ + protected function tryAgainIfCausedByLostConnection(QueryException $e, string $query, array $bindings, Closure $callback): mixed + { + if ($this->causedByLostConnection($e->getPrevious())) { + $this->reconnect(); + + return $this->runQueryCallback($query, $bindings, $callback); + } + + throw $e; + } + + /** + * Reconnect to the database. + * + * @throws LostConnectionException + */ + public function reconnect(): mixed + { + if (is_callable($this->reconnector)) { + return call_user_func($this->reconnector, $this); + } + + throw new LostConnectionException('Lost connection and no reconnector available.'); + } + + /** + * Reconnect to the database if a PDO connection is missing. + */ + public function reconnectIfMissingConnection(): void + { + if (is_null($this->pdo)) { + $this->reconnect(); + } + } + + /** + * Disconnect from the underlying PDO connection. + */ + public function disconnect(): void + { + $this->setPdo(null)->setReadPdo(null); + } + + /** + * Register a hook to be run just before a database transaction is started. + */ + public function beforeStartingTransaction(Closure $callback): static + { + $this->beforeStartingTransaction[] = $callback; + + return $this; + } + + /** + * Register a hook to be run just before a database query is executed. + */ + public function beforeExecuting(Closure $callback): static + { + $this->beforeExecutingCallbacks[] = $callback; + + return $this; + } + + /** + * Clear all hooks registered to run before a database query. + * + * Used by connection pooling to prevent callback leaks between requests. + */ + public function clearBeforeExecutingCallbacks(): void + { + $this->beforeExecutingCallbacks = []; + } + + /** + * Reset all per-request state for pool release. + * + * Called when a connection is returned to the pool to ensure the next + * coroutine/request gets a clean connection without leaked state. + */ + public function resetForPool(): void + { + // Clear registered callbacks + $this->beforeExecutingCallbacks = []; + $this->beforeStartingTransaction = []; + + // Reset query logging + $this->queryLog = []; + $this->loggingQueries = false; + + // Reset query duration tracking + $this->totalQueryDuration = 0.0; + $this->queryDurationHandlers = []; + + // Reset connection routing + $this->readOnWriteConnection = false; + + // Reset pretend mode (defensive - normally reset by finally block) + $this->pretending = false; + + // Reset record modification state + $this->recordsModified = false; + } + + /** + * Get the number of SQL execution errors on this connection. + * + * Used by connection pooling to detect stale connections. + */ + public function getErrorCount(): int + { + return $this->errorCount; + } + + /** + * Register a database query listener with the connection. + */ + public function listen(Closure $callback): void + { + $this->events?->listen(Events\QueryExecuted::class, $callback); + } + + /** + * Fire an event for this connection. + */ + protected function fireConnectionEvent(string $event): void + { + $this->events?->dispatch(match ($event) { + 'beganTransaction' => new TransactionBeginning($this), + 'committed' => new TransactionCommitted($this), + 'committing' => new TransactionCommitting($this), + 'rollingBack' => new TransactionRolledBack($this), + default => null, + }); + } + + /** + * Fire the given event if possible. + */ + protected function event(mixed $event): void + { + $this->events?->dispatch($event); + } + + /** + * Get a new raw query expression. + */ + public function raw(mixed $value): Expression + { + return new Expression($value); + } + + /** + * Escape a value for safe SQL embedding. + * + * @throws RuntimeException + */ + public function escape(string|float|int|bool|null $value, bool $binary = false): string + { + if ($value === null) { + return 'null'; + } + if ($binary) { + return $this->escapeBinary($value); + } + if (is_int($value) || is_float($value)) { + return (string) $value; + } + if (is_bool($value)) { + return $this->escapeBool($value); + } + if (str_contains($value, "\00")) { + throw new RuntimeException('Strings with null bytes cannot be escaped. Use the binary escape option.'); + } + + if (preg_match('//u', $value) === false) { + throw new RuntimeException('Strings with invalid UTF-8 byte sequences cannot be escaped.'); + } + + return $this->escapeString($value); + } + + /** + * Escape a string value for safe SQL embedding. + */ + protected function escapeString(string $value): string + { + return $this->getReadPdo()->quote($value); + } + + /** + * Escape a boolean value for safe SQL embedding. + */ + protected function escapeBool(bool $value): string + { + return $value ? '1' : '0'; + } + + /** + * Escape a binary value for safe SQL embedding. + * + * @throws RuntimeException + */ + protected function escapeBinary(string $value): string + { + throw new RuntimeException('The database connection does not support escaping binary values.'); + } + + /** + * Determine if the database connection has modified any database records. + */ + public function hasModifiedRecords(): bool + { + return $this->recordsModified; + } + + /** + * Indicate if any records have been modified. + */ + public function recordsHaveBeenModified(bool $value = true): void + { + if (! $this->recordsModified) { + $this->recordsModified = $value; + } + } + + /** + * Set the record modification state. + * + * @return $this + */ + public function setRecordModificationState(bool $value) + { + $this->recordsModified = $value; + + return $this; + } + + /** + * Reset the record modification state. + */ + public function forgetRecordModificationState(): void + { + $this->recordsModified = false; + } + + /** + * Indicate that the connection should use the write PDO connection for reads. + */ + public function useWriteConnectionWhenReading(bool $value = true): static + { + $this->readOnWriteConnection = $value; + + return $this; + } + + /** + * Get the current PDO connection. + */ + public function getPdo(): PDO + { + $this->latestPdoTypeRetrieved = 'write'; + + if ($this->pdo instanceof Closure) { + return $this->pdo = call_user_func($this->pdo); + } + + return $this->pdo; + } + + /** + * Get the current PDO connection parameter without executing any reconnect logic. + */ + public function getRawPdo(): PDO|Closure|null + { + return $this->pdo; + } + + /** + * Get the current PDO connection used for reading. + */ + public function getReadPdo(): PDO + { + if ($this->transactions > 0) { + return $this->getPdo(); + } + + if ($this->readOnWriteConnection + || ($this->recordsModified && $this->getConfig('sticky'))) { + return $this->getPdo(); + } + + $this->latestPdoTypeRetrieved = 'read'; + + if ($this->readPdo instanceof Closure) { + return $this->readPdo = call_user_func($this->readPdo); + } + + return $this->readPdo ?: $this->getPdo(); + } + + /** + * Get the current read PDO connection parameter without executing any reconnect logic. + */ + public function getRawReadPdo(): PDO|Closure|null + { + return $this->readPdo; + } + + /** + * Set the PDO connection. + */ + public function setPdo(PDO|Closure|null $pdo): static + { + $this->transactions = 0; + + $this->pdo = $pdo; + + return $this; + } + + /** + * Set the PDO connection used for reading. + */ + public function setReadPdo(PDO|Closure|null $pdo): static + { + $this->readPdo = $pdo; + + return $this; + } + + /** + * Set the read PDO connection configuration. + */ + public function setReadPdoConfig(array $config): static + { + $this->readPdoConfig = $config; + + return $this; + } + + /** + * Set the reconnect instance on the connection. + */ + public function setReconnector(callable $reconnector): static + { + $this->reconnector = $reconnector; + + return $this; + } + + /** + * Get the database connection name. + */ + public function getName(): ?string + { + return $this->getConfig('name'); + } + + /** + * Get the database connection with its read / write type. + */ + public function getNameWithReadWriteType(): ?string + { + $name = $this->getName() . ($this->readWriteType ? '::' . $this->readWriteType : ''); + + return empty($name) ? null : $name; + } + + /** + * Get an option from the configuration options. + */ + public function getConfig(?string $option = null): mixed + { + return Arr::get($this->config, $option); + } + + /** + * Get the basic connection information as an array for debugging. + */ + protected function getConnectionDetails(): array + { + $config = $this->latestReadWriteTypeUsed() === 'read' + ? $this->readPdoConfig + : $this->config; + + return [ + 'driver' => $this->getDriverName(), + 'name' => $this->getNameWithReadWriteType(), + 'host' => $config['host'] ?? null, + 'port' => $config['port'] ?? null, + 'database' => $config['database'] ?? null, + 'unix_socket' => $config['unix_socket'] ?? null, + ]; + } + + /** + * Get the PDO driver name. + */ + public function getDriverName(): string + { + return $this->getConfig('driver'); + } + + /** + * Get a human-readable name for the given connection driver. + */ + public function getDriverTitle(): string + { + return $this->getDriverName(); + } + + /** + * Get the query grammar used by the connection. + */ + public function getQueryGrammar(): QueryGrammar + { + return $this->queryGrammar; + } + + /** + * Set the query grammar used by the connection. + */ + public function setQueryGrammar(Query\Grammars\Grammar $grammar): static + { + $this->queryGrammar = $grammar; + + return $this; + } + + /** + * Get the schema grammar used by the connection. + */ + public function getSchemaGrammar(): ?Schema\Grammars\Grammar + { + return $this->schemaGrammar; + } + + /** + * Set the schema grammar used by the connection. + */ + public function setSchemaGrammar(Schema\Grammars\Grammar $grammar): static + { + $this->schemaGrammar = $grammar; + + return $this; + } + + /** + * Get the query post processor used by the connection. + */ + public function getPostProcessor(): Processor + { + return $this->postProcessor; + } + + /** + * Set the query post processor used by the connection. + */ + public function setPostProcessor(Processor $processor): static + { + $this->postProcessor = $processor; + + return $this; + } + + /** + * Get the event dispatcher used by the connection. + */ + public function getEventDispatcher(): ?Dispatcher + { + return $this->events; + } + + /** + * Set the event dispatcher instance on the connection. + */ + public function setEventDispatcher(Dispatcher $events): static + { + $this->events = $events; + + return $this; + } + + /** + * Unset the event dispatcher for this connection. + */ + public function unsetEventDispatcher(): void + { + $this->events = null; + } + + /** + * Run the statement to start a new transaction. + */ + protected function executeBeginTransactionStatement(): void + { + $this->getPdo()->beginTransaction(); + } + + /** + * Set the transaction manager instance on the connection. + */ + public function setTransactionManager(DatabaseTransactionsManager $manager): static + { + $this->transactionsManager = $manager; + + return $this; + } + + /** + * Get the transaction manager instance. + */ + public function getTransactionManager(): ?DatabaseTransactionsManager + { + return $this->transactionsManager; + } + + /** + * Unset the transaction manager for this connection. + */ + public function unsetTransactionManager(): void + { + $this->transactionsManager = null; + } + + /** + * Determine if the connection is in a "dry run". + */ + public function pretending(): bool + { + return $this->pretending === true; + } + + /** + * Get the connection query log. + * + * @return array{query: string, bindings: array, time: null|float}[] + */ + public function getQueryLog(): array + { + return $this->queryLog; + } + + /** + * Get the connection query log with embedded bindings. + */ + public function getRawQueryLog(): array + { + return array_map(fn (array $log) => [ + 'raw_query' => $this->queryGrammar->substituteBindingsIntoRawSql( + $log['query'], + $this->prepareBindings($log['bindings']) + ), + 'time' => $log['time'], + ], $this->getQueryLog()); + } + + /** + * Clear the query log. + */ + public function flushQueryLog(): void + { + $this->queryLog = []; + } + + /** + * Enable the query log on the connection. + */ + public function enableQueryLog(): void + { + $this->loggingQueries = true; + } + + /** + * Disable the query log on the connection. + */ + public function disableQueryLog(): void + { + $this->loggingQueries = false; + } + + /** + * Determine whether we're logging queries. + */ + public function logging(): bool + { + return $this->loggingQueries; + } + + /** + * Get the name of the connected database. + */ + public function getDatabaseName(): string + { + return $this->database; + } + + /** + * Set the name of the connected database. + */ + public function setDatabaseName(string $database): static + { + $this->database = $database; + + return $this; + } + + /** + * Set the read / write type of the connection. + */ + public function setReadWriteType(?string $readWriteType): static + { + $this->readWriteType = $readWriteType; + + return $this; + } + + /** + * Retrieve the latest read / write type used. + * + * @return null|'read'|'write' + */ + protected function latestReadWriteTypeUsed(): ?string + { + return $this->readWriteType ?? $this->latestPdoTypeRetrieved; + } + + /** + * Get the table prefix for the connection. + */ + public function getTablePrefix(): string + { + return $this->tablePrefix; + } + + /** + * Set the table prefix in use by the connection. + */ + public function setTablePrefix(string $prefix): static + { + $this->tablePrefix = $prefix; + + return $this; + } + + /** + * Execute the given callback without table prefix. + */ + public function withoutTablePrefix(Closure $callback): mixed + { + $tablePrefix = $this->getTablePrefix(); + + $this->setTablePrefix(''); + + try { + return $callback($this); + } finally { + $this->setTablePrefix($tablePrefix); + } + } + + /** + * Get the server version for the connection. + */ + public function getServerVersion(): string + { + return $this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + } + + /** + * Register a connection resolver. + */ + public static function resolverFor(string $driver, Closure $callback): void + { + static::$resolvers[$driver] = $callback; + } + + /** + * Get the connection resolver for the given driver. + */ + public static function getResolver(string $driver): ?Closure + { + return static::$resolvers[$driver] ?? null; + } + + /** + * Prepare the instance for cloning. + */ + public function __clone(): void + { + // When cloning, re-initialize grammars to reference cloned connection... + $this->useDefaultQueryGrammar(); + + if (! is_null($this->schemaGrammar)) { + $this->useDefaultSchemaGrammar(); + } + } +} diff --git a/src/database/src/ConnectionInterface.php b/src/database/src/ConnectionInterface.php new file mode 100644 index 000000000..7ca2cab78 --- /dev/null +++ b/src/database/src/ConnectionInterface.php @@ -0,0 +1,173 @@ +factory = $container->get(PoolFactory::class); + } + + /** + * Get a database connection instance. + * + * The connection is retrieved from a pool and stored in the current + * coroutine's context. When the coroutine ends, the connection is + * automatically released back to the pool. + */ + public function connection(UnitEnum|string|null $name = null): ConnectionInterface + { + $name = enum_value($name) ?: $this->getDefaultConnection(); + $contextKey = $this->getContextKey($name); + + // Check if this coroutine already has a connection + if (Context::has($contextKey)) { + $connection = Context::get($contextKey); + if ($connection instanceof ConnectionInterface) { + return $connection; + } + } + + // Get a pooled connection wrapper from the pool + $pool = $this->factory->getPool($name); + + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + + try { + // Get the actual database connection from the wrapper + $connection = $pooledConnection->getConnection(); + + // Store in context for this coroutine + Context::set($contextKey, $connection); + } finally { + // Schedule cleanup when coroutine ends + if (Coroutine::inCoroutine()) { + defer(function () use ($pooledConnection, $contextKey) { + Context::set($contextKey, null); + $pooledConnection->release(); + }); + } + } + + return $connection; + } + + /** + * Get the default connection name. + * + * Checks Context first for per-coroutine override (from usingConnection()), + * then falls back to the configured default. + */ + public function getDefaultConnection(): string + { + return Context::get(self::DEFAULT_CONNECTION_CONTEXT_KEY) ?? $this->default; + } + + /** + * Set the default connection name. + */ + public function setDefaultConnection(string $name): void + { + $this->default = $name; + } + + /** + * Get the context key for storing a connection. + */ + protected function getContextKey(string $name): string + { + return sprintf('database.connection.%s', $name); + } +} diff --git a/src/database/src/ConnectionResolverInterface.php b/src/database/src/ConnectionResolverInterface.php new file mode 100644 index 000000000..90296c0cd --- /dev/null +++ b/src/database/src/ConnectionResolverInterface.php @@ -0,0 +1,25 @@ +parseConfig($config, $name); + + if (isset($config['read'])) { + return $this->createReadWriteConnection($config); + } + + return $this->createSingleConnection($config); + } + + /** + * Parse and prepare the database configuration. + */ + protected function parseConfig(array $config, ?string $name): array + { + return Arr::add(Arr::add($config, 'prefix', ''), 'name', $name); + } + + /** + * Create a single database connection instance. + */ + protected function createSingleConnection(array $config): Connection + { + $pdo = $this->createPdoResolver($config); + + return $this->createConnection( + $config['driver'], + $pdo, + $config['database'], + $config['prefix'], + $config + ); + } + + /** + * Create a read / write database connection instance. + */ + protected function createReadWriteConnection(array $config): Connection + { + $connection = $this->createSingleConnection($this->getWriteConfig($config)); + + return $connection + ->setReadPdo($this->createReadPdo($config)) + ->setReadPdoConfig($this->getReadConfig($config)); + } + + /** + * Create a new PDO instance for reading. + */ + protected function createReadPdo(array $config): Closure + { + return $this->createPdoResolver($this->getReadConfig($config)); + } + + /** + * Get the read configuration for a read / write connection. + */ + protected function getReadConfig(array $config): array + { + return $this->mergeReadWriteConfig( + $config, + $this->getReadWriteConfig($config, 'read') + ); + } + + /** + * Get the write configuration for a read / write connection. + */ + protected function getWriteConfig(array $config): array + { + return $this->mergeReadWriteConfig( + $config, + $this->getReadWriteConfig($config, 'write') + ); + } + + /** + * Get a read / write level configuration. + */ + protected function getReadWriteConfig(array $config, string $type): array + { + return isset($config[$type][0]) + ? Arr::random($config[$type]) + : $config[$type]; + } + + /** + * Merge a configuration for a read / write connection. + */ + protected function mergeReadWriteConfig(array $config, array $merge): array + { + return Arr::except(array_merge($config, $merge), ['read', 'write']); + } + + /** + * Create a new Closure that resolves to a PDO instance. + */ + protected function createPdoResolver(array $config): Closure + { + return array_key_exists('host', $config) + ? $this->createPdoResolverWithHosts($config) + : $this->createPdoResolverWithoutHosts($config); + } + + /** + * Create a new Closure that resolves to a PDO instance with a specific host or an array of hosts. + */ + protected function createPdoResolverWithHosts(array $config): Closure + { + return function () use ($config) { + foreach (Arr::shuffle($this->parseHosts($config)) as $host) { + $config['host'] = $host; + + try { + return $this->createConnector($config)->connect($config); + } catch (PDOException $e) { + continue; + } + } + + if (isset($e)) { + throw $e; + } + }; + } + + /** + * Parse the hosts configuration item into an array. + * + * @throws InvalidArgumentException + */ + protected function parseHosts(array $config): array + { + $hosts = Arr::wrap($config['host']); + + if (empty($hosts)) { + throw new InvalidArgumentException('Database hosts array is empty.'); + } + + return $hosts; + } + + /** + * Create a new Closure that resolves to a PDO instance where there is no configured host. + */ + protected function createPdoResolverWithoutHosts(array $config): Closure + { + return fn () => $this->createConnector($config)->connect($config); + } + + /** + * Create a connector instance based on the configuration. + * + * @throws InvalidArgumentException + */ + public function createConnector(array $config): ConnectorInterface + { + if (! isset($config['driver'])) { + throw new InvalidArgumentException('A driver must be specified.'); + } + + if ($this->container->bound($key = "db.connector.{$config['driver']}")) { + return $this->container->get($key); + } + + return match ($config['driver']) { + 'mysql' => new MySqlConnector(), + 'mariadb' => new MariaDbConnector(), + 'pgsql' => new PostgresConnector(), + 'sqlite' => new SQLiteConnector(), + default => throw new InvalidArgumentException("Unsupported driver [{$config['driver']}]."), + }; + } + + /** + * Create a new connection instance. + * + * @throws InvalidArgumentException + */ + protected function createConnection(string $driver, PDO|Closure $connection, string $database, string $prefix = '', array $config = []): Connection + { + if ($resolver = Connection::getResolver($driver)) { + return $resolver($connection, $database, $prefix, $config); + } + + return match ($driver) { + 'mysql' => new MySqlConnection($connection, $database, $prefix, $config), + 'mariadb' => new MariaDbConnection($connection, $database, $prefix, $config), + 'pgsql' => new PostgresConnection($connection, $database, $prefix, $config), + 'sqlite' => new SQLiteConnection($connection, $database, $prefix, $config), + default => throw new InvalidArgumentException("Unsupported driver [{$driver}]."), + }; + } +} diff --git a/src/database/src/Connectors/Connector.php b/src/database/src/Connectors/Connector.php new file mode 100755 index 000000000..88adc962a --- /dev/null +++ b/src/database/src/Connectors/Connector.php @@ -0,0 +1,106 @@ + PDO::CASE_NATURAL, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + + /** + * Create a new PDO connection. + * + * @throws Exception + */ + public function createConnection(string $dsn, array $config, array $options): PDO + { + [$username, $password] = [ + $config['username'] ?? null, $config['password'] ?? null, + ]; + + try { + return $this->createPdoConnection( + $dsn, + $username, + $password, + $options + ); + } catch (Exception $e) { + return $this->tryAgainIfCausedByLostConnection( + $e, + $dsn, + $username, + $password, + $options + ); + } + } + + /** + * Create a new PDO connection instance. + */ + protected function createPdoConnection(string $dsn, ?string $username, #[SensitiveParameter] ?string $password, array $options): PDO + { + return version_compare(PHP_VERSION, '8.4.0', '<') + ? new PDO($dsn, $username, $password, $options) + : PDO::connect($dsn, $username, $password, $options); /* @phpstan-ignore staticMethod.notFound (PHP 8.4) */ + } + + /** + * Handle an exception that occurred during connect execution. + * + * @throws Throwable + */ + protected function tryAgainIfCausedByLostConnection(Throwable $e, string $dsn, ?string $username, #[SensitiveParameter] ?string $password, array $options): PDO + { + if ($this->causedByLostConnection($e)) { + return $this->createPdoConnection($dsn, $username, $password, $options); + } + + throw $e; + } + + /** + * Get the PDO options based on the configuration. + */ + public function getOptions(array $config): array + { + $options = $config['options'] ?? []; + + return array_diff_key($this->options, $options) + $options; + } + + /** + * Get the default PDO connection options. + */ + public function getDefaultOptions(): array + { + return $this->options; + } + + /** + * Set the default PDO connection options. + */ + public function setDefaultOptions(array $options): void + { + $this->options = $options; + } +} diff --git a/src/database/src/Connectors/ConnectorInterface.php b/src/database/src/Connectors/ConnectorInterface.php new file mode 100644 index 000000000..3b285233a --- /dev/null +++ b/src/database/src/Connectors/ConnectorInterface.php @@ -0,0 +1,15 @@ +getDsn($config); + + $options = $this->getOptions($config); + + // We need to grab the PDO options that should be used while making the brand + // new connection instance. The PDO options control various aspects of the + // connection's behavior, and some might be specified by the developers. + $connection = $this->createConnection($dsn, $config, $options); + + if (! empty($config['database']) + && (! isset($config['use_db_after_connecting']) + || $config['use_db_after_connecting'])) { + $connection->exec("use `{$config['database']}`;"); + } + + $this->configureConnection($connection, $config); + + return $connection; + } + + /** + * Create a DSN string from a configuration. + * + * Chooses socket or host/port based on the 'unix_socket' config value. + */ + protected function getDsn(array $config): string + { + return $this->hasSocket($config) + ? $this->getSocketDsn($config) + : $this->getHostDsn($config); + } + + /** + * Determine if the given configuration array has a UNIX socket value. + */ + protected function hasSocket(array $config): bool + { + return isset($config['unix_socket']) && ! empty($config['unix_socket']); + } + + /** + * Get the DSN string for a socket configuration. + */ + protected function getSocketDsn(array $config): string + { + return "mysql:unix_socket={$config['unix_socket']};dbname={$config['database']}"; + } + + /** + * Get the DSN string for a host / port configuration. + */ + protected function getHostDsn(array $config): string + { + return isset($config['port']) + ? "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']}" + : "mysql:host={$config['host']};dbname={$config['database']}"; + } + + /** + * Configure the given PDO connection. + */ + protected function configureConnection(PDO $connection, array $config): void + { + if (isset($config['isolation_level'])) { + $connection->exec(sprintf('SET SESSION TRANSACTION ISOLATION LEVEL %s;', $config['isolation_level'])); + } + + $statements = []; + + if (isset($config['charset'])) { + if (isset($config['collation'])) { + $statements[] = sprintf("NAMES '%s' COLLATE '%s'", $config['charset'], $config['collation']); + } else { + $statements[] = sprintf("NAMES '%s'", $config['charset']); + } + } + + if (isset($config['timezone'])) { + $statements[] = sprintf("time_zone='%s'", $config['timezone']); + } + + $sqlMode = $this->getSqlMode($connection, $config); + + if ($sqlMode !== null) { + $statements[] = sprintf("SESSION sql_mode='%s'", $sqlMode); + } + + if ($statements !== []) { + $connection->exec(sprintf('SET %s;', implode(', ', $statements))); + } + } + + /** + * Get the sql_mode value. + */ + protected function getSqlMode(PDO $connection, array $config): ?string + { + if (isset($config['modes'])) { + return implode(',', $config['modes']); + } + + if (! isset($config['strict'])) { + return null; + } + + if (! $config['strict']) { + return 'NO_ENGINE_SUBSTITUTION'; + } + + $version = $config['version'] ?? $connection->getAttribute(PDO::ATTR_SERVER_VERSION); + + if (version_compare($version, '8.0.11', '>=')) { + return 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; + } + + return 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION'; + } +} diff --git a/src/database/src/Connectors/PostgresConnector.php b/src/database/src/Connectors/PostgresConnector.php new file mode 100755 index 000000000..e7a1193c6 --- /dev/null +++ b/src/database/src/Connectors/PostgresConnector.php @@ -0,0 +1,160 @@ + PDO::CASE_NATURAL, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false, + ]; + + /** + * Establish a database connection. + */ + public function connect(array $config): PDO + { + // First we'll create the basic DSN and connection instance connecting to the + // using the configuration option specified by the developer. We will also + // set the default character set on the connections to UTF-8 by default. + $connection = $this->createConnection( + $this->getDsn($config), + $config, + $this->getOptions($config) + ); + + $this->configureIsolationLevel($connection, $config); + + // Next, we will check to see if a timezone has been specified in this config + // and if it has we will issue a statement to modify the timezone with the + // database. Setting this DB timezone is an optional configuration item. + $this->configureTimezone($connection, $config); + + $this->configureSearchPath($connection, $config); + + $this->configureSynchronousCommit($connection, $config); + + return $connection; + } + + /** + * Create a DSN string from a configuration. + */ + protected function getDsn(array $config): string + { + // First we will create the basic DSN setup as well as the port if it is in + // in the configuration options. This will give us the basic DSN we will + // need to establish the PDO connections and return them back for use. + extract($config, EXTR_SKIP); + + $host = isset($host) ? "host={$host};" : ''; + + // Sometimes - users may need to connect to a database that has a different + // name than the database used for "information_schema" queries. This is + // typically the case if using "pgbouncer" type software when pooling. + $database = $connect_via_database ?? $database ?? null; + $port = $connect_via_port ?? $port ?? null; + + $dsn = "pgsql:{$host}dbname='{$database}'"; + + // If a port was specified, we will add it to this Postgres DSN connections + // format. Once we have done that we are ready to return this connection + // string back out for usage, as this has been fully constructed here. + if (! is_null($port)) { + $dsn .= ";port={$port}"; + } + + if (isset($charset)) { + $dsn .= ";client_encoding='{$charset}'"; + } + + // Postgres allows an application_name to be set by the user and this name is + // used to when monitoring the application with pg_stat_activity. So we'll + // determine if the option has been specified and run a statement if so. + if (isset($application_name)) { + $dsn .= ";application_name='" . str_replace("'", "\\'", $application_name) . "'"; + } + + return $this->addSslOptions($dsn, $config); + } + + /** + * Add the SSL options to the DSN. + */ + protected function addSslOptions(string $dsn, array $config): string + { + foreach (['sslmode', 'sslcert', 'sslkey', 'sslrootcert'] as $option) { + if (isset($config[$option])) { + $dsn .= ";{$option}={$config[$option]}"; + } + } + + return $dsn; + } + + /** + * Set the connection transaction isolation level. + */ + protected function configureIsolationLevel(PDO $connection, array $config): void + { + if (isset($config['isolation_level'])) { + $connection->prepare("set session characteristics as transaction isolation level {$config['isolation_level']}")->execute(); + } + } + + /** + * Set the timezone on the connection. + */ + protected function configureTimezone(PDO $connection, array $config): void + { + if (isset($config['timezone'])) { + $timezone = $config['timezone']; + + $connection->prepare("set time zone '{$timezone}'")->execute(); + } + } + + /** + * Set the "search_path" on the database connection. + */ + protected function configureSearchPath(PDO $connection, array $config): void + { + if (isset($config['search_path']) || isset($config['schema'])) { + $searchPath = $this->quoteSearchPath( + $this->parseSearchPath($config['search_path'] ?? $config['schema']) + ); + + $connection->prepare("set search_path to {$searchPath}")->execute(); + } + } + + /** + * Format the search path for the DSN. + */ + protected function quoteSearchPath(array $searchPath): string + { + return count($searchPath) === 1 ? '"' . $searchPath[0] . '"' : '"' . implode('", "', $searchPath) . '"'; + } + + /** + * Configure the synchronous_commit setting. + */ + protected function configureSynchronousCommit(PDO $connection, array $config): void + { + if (isset($config['synchronous_commit'])) { + $connection->prepare("set synchronous_commit to '{$config['synchronous_commit']}'")->execute(); + } + } +} diff --git a/src/database/src/Connectors/SQLiteConnector.php b/src/database/src/Connectors/SQLiteConnector.php new file mode 100755 index 000000000..f1fde8c2b --- /dev/null +++ b/src/database/src/Connectors/SQLiteConnector.php @@ -0,0 +1,126 @@ +getOptions($config); + + $path = $this->parseDatabasePath($config['database']); + + $connection = $this->createConnection("sqlite:{$path}", $config, $options); + + $this->configurePragmas($connection, $config); + $this->configureForeignKeyConstraints($connection, $config); + $this->configureBusyTimeout($connection, $config); + $this->configureJournalMode($connection, $config); + $this->configureSynchronous($connection, $config); + + return $connection; + } + + /** + * Get the absolute database path. + * + * @throws \Hypervel\Database\SQLiteDatabaseDoesNotExistException + */ + protected function parseDatabasePath(string $path): string + { + $database = $path; + + // SQLite supports "in-memory" databases that only last as long as the owning + // connection does. These are useful for tests or for short lifetime store + // querying. In-memory databases shall be anonymous (:memory:) or named. + if ($path === ':memory:' + || str_contains($path, '?mode=memory') + || str_contains($path, '&mode=memory') + ) { + return $path; + } + + $path = realpath($path) ?: realpath(base_path($path)); + + // Here we'll verify that the SQLite database exists before going any further + // as the developer probably wants to know if the database exists and this + // SQLite driver will not throw any exception if it does not by default. + if ($path === false) { + throw new SQLiteDatabaseDoesNotExistException($database); + } + + return $path; + } + + /** + * Set miscellaneous user-configured pragmas. + */ + protected function configurePragmas(PDO $connection, array $config): void + { + if (! isset($config['pragmas'])) { + return; + } + + foreach ($config['pragmas'] as $pragma => $value) { + $connection->prepare("pragma {$pragma} = {$value}")->execute(); + } + } + + /** + * Enable or disable foreign key constraints if configured. + */ + protected function configureForeignKeyConstraints(PDO $connection, array $config): void + { + if (! isset($config['foreign_key_constraints'])) { + return; + } + + $foreignKeys = $config['foreign_key_constraints'] ? 1 : 0; + + $connection->prepare("pragma foreign_keys = {$foreignKeys}")->execute(); + } + + /** + * Set the busy timeout if configured. + */ + protected function configureBusyTimeout(PDO $connection, array $config): void + { + if (! isset($config['busy_timeout'])) { + return; + } + + $connection->prepare("pragma busy_timeout = {$config['busy_timeout']}")->execute(); + } + + /** + * Set the journal mode if configured. + */ + protected function configureJournalMode(PDO $connection, array $config): void + { + if (! isset($config['journal_mode'])) { + return; + } + + $connection->prepare("pragma journal_mode = {$config['journal_mode']}")->execute(); + } + + /** + * Set the synchronous mode if configured. + */ + protected function configureSynchronous(PDO $connection, array $config): void + { + if (! isset($config['synchronous'])) { + return; + } + + $connection->prepare("pragma synchronous = {$config['synchronous']}")->execute(); + } +} diff --git a/src/core/class_map/Database/Commands/Migrations/BaseCommand.php b/src/database/src/Console/Migrations/BaseCommand.php similarity index 51% rename from src/core/class_map/Database/Commands/Migrations/BaseCommand.php rename to src/database/src/Console/Migrations/BaseCommand.php index 49f2e8e97..ee6fd6df8 100644 --- a/src/core/class_map/Database/Commands/Migrations/BaseCommand.php +++ b/src/database/src/Console/Migrations/BaseCommand.php @@ -2,26 +2,34 @@ declare(strict_types=1); -namespace Hyperf\Database\Commands\Migrations; +namespace Hypervel\Database\Console\Migrations; -use Hyperf\Collection\Collection; -use Hyperf\Command\Command; +use Hypervel\Console\Command; +use Hypervel\Database\Migrations\Migrator; +use Hypervel\Support\Collection; abstract class BaseCommand extends Command { /** - * Get all the migration paths. + * The migrator instance. + */ + protected Migrator $migrator; + + /** + * Get all of the migration paths. + * + * @return string[] */ protected function getMigrationPaths(): array { // Here, we will check to see if a path option has been defined. If it has we will // use the path relative to the root of the installation folder so our database // migrations may be run for any customized path from within the application. - if ($this->input->hasOption('path') && $this->input->getOption('path')) { - return Collection::make($this->input->getOption('path'))->map(function ($path) { + if ($this->input->hasOption('path') && $this->option('path')) { + return (new Collection($this->option('path')))->map(function ($path) { return ! $this->usingRealPath() - ? BASE_PATH . DIRECTORY_SEPARATOR . $path - : $path; + ? base_path($path) + : $path; })->all(); } @@ -33,21 +41,17 @@ protected function getMigrationPaths(): array /** * Determine if the given path(s) are pre-resolved "real" paths. - * - * @return bool */ - protected function usingRealPath() + protected function usingRealPath(): bool { - return $this->input->hasOption('realpath') && $this->input->getOption('realpath'); + return $this->input->hasOption('realpath') && $this->option('realpath'); } /** * Get the path to the migration directory. - * - * @return string */ - protected function getMigrationPath() + protected function getMigrationPath(): string { - return BASE_PATH . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations'; + return database_path('migrations'); } } diff --git a/src/database/src/Console/Migrations/FreshCommand.php b/src/database/src/Console/Migrations/FreshCommand.php new file mode 100644 index 000000000..9664eba0a --- /dev/null +++ b/src/database/src/Console/Migrations/FreshCommand.php @@ -0,0 +1,103 @@ +confirmToProceed()) { + return self::FAILURE; + } + + $database = $this->option('database'); + + $this->migrator->usingConnection($database, function () use ($database) { + if ($this->migrator->repositoryExists()) { + $this->newLine(); + + $this->components->task('Dropping all tables', fn () => $this->callSilent('db:wipe', array_filter([ + '--database' => $database, + '--drop-views' => $this->option('drop-views'), + '--drop-types' => $this->option('drop-types'), + '--force' => true, + ])) == 0); + } + }); + + $this->newLine(); + + $this->call('migrate', array_filter([ + '--database' => $database, + '--path' => $this->option('path'), + '--realpath' => $this->option('realpath'), + '--schema-path' => $this->option('schema-path'), + '--force' => true, + '--step' => $this->option('step'), + ])); + + $this->dispatcher->dispatch( + new DatabaseRefreshed($database, $this->needsSeeding()) + ); + + if ($this->needsSeeding()) { + $this->runSeeder($database); + } + + return 0; + } + + /** + * Determine if the developer has requested database seeding. + */ + protected function needsSeeding(): bool + { + return $this->option('seed') || $this->option('seeder'); + } + + /** + * Run the database seeder command. + */ + protected function runSeeder(?string $database): void + { + $this->call('db:seed', array_filter([ + '--database' => $database, + '--class' => $this->option('seeder') ?: 'Database\Seeders\DatabaseSeeder', + '--force' => true, + ])); + } +} diff --git a/src/database/src/Console/Migrations/InstallCommand.php b/src/database/src/Console/Migrations/InstallCommand.php new file mode 100644 index 000000000..03c352a2b --- /dev/null +++ b/src/database/src/Console/Migrations/InstallCommand.php @@ -0,0 +1,36 @@ +repository->setSource($this->option('database')); + + if (! $this->repository->repositoryExists()) { + $this->repository->createRepository(); + } + + $this->components->info('Migration table created successfully.'); + } +} diff --git a/src/database/src/Console/Migrations/MakeMigrationCommand.php b/src/database/src/Console/Migrations/MakeMigrationCommand.php new file mode 100644 index 000000000..88df41c84 --- /dev/null +++ b/src/database/src/Console/Migrations/MakeMigrationCommand.php @@ -0,0 +1,91 @@ +argument('name'))); + + $table = $this->option('table'); + + $create = $this->option('create') ?: false; + + // If no table was given as an option but a create option is given then we + // will use the "create" option as the table name. This allows the devs + // to pass a table name into this option as a short-cut for creating. + if (! $table && is_string($create)) { + $table = $create; + + $create = true; + } + + // Next, we will attempt to guess the table name if this the migration has + // "create" in the name. This will allow us to provide a convenient way + // of creating migrations that create new tables for the application. + if (! $table) { + [$table, $create] = TableGuesser::guess($name); + } + + // Now we are ready to write the migration out to disk. Once we've written + // the migration out, we will dump-autoload for the entire framework to + // make sure that the migrations are registered by the class loaders. + $this->writeMigration($name, $table, $create); + } + + /** + * Write the migration file to disk. + */ + protected function writeMigration(string $name, ?string $table, bool $create): void + { + $file = $this->creator->create( + $name, + $this->getMigrationPath(), + $table, + $create + ); + + $this->components->info(sprintf('Migration [%s] created successfully.', $file)); + } + + /** + * Get migration path (either specified by '--path' option or default location). + */ + protected function getMigrationPath(): string + { + if (! is_null($targetPath = $this->option('path'))) { + return ! $this->usingRealPath() + ? base_path($targetPath) + : $targetPath; + } + + return parent::getMigrationPath(); + } +} diff --git a/src/database/src/Console/Migrations/MigrateCommand.php b/src/database/src/Console/Migrations/MigrateCommand.php new file mode 100644 index 000000000..4dba0b1bc --- /dev/null +++ b/src/database/src/Console/Migrations/MigrateCommand.php @@ -0,0 +1,167 @@ +confirmToProceed()) { + return 1; + } + + try { + $this->runMigrations(); + } catch (Throwable $e) { + if ($this->option('graceful')) { + $this->components->warn($e->getMessage()); + + return 0; + } + + throw $e; + } + + return 0; + } + + /** + * Run the pending migrations. + */ + protected function runMigrations(): void + { + $this->migrator->usingConnection($this->option('database'), function () { + $this->prepareDatabase(); + + // Next, we will check to see if a path option has been defined. If it has + // we will use the path relative to the root of this installation folder + // so that migrations may be run for any path within the applications. + $this->migrator->setOutput($this->output) + ->run($this->getMigrationPaths(), [ + 'pretend' => $this->option('pretend'), + 'step' => $this->option('step'), + ]); + + // Finally, if the "seed" option has been given, we will re-run the database + // seed task to re-populate the database, which is convenient when adding + // a migration and a seed at the same time, as it is only this command. + if ($this->option('seed') && ! $this->option('pretend')) { + $this->call('db:seed', [ + '--class' => $this->option('seeder') ?: 'Database\Seeders\DatabaseSeeder', + '--force' => true, + ]); + } + }); + } + + /** + * Prepare the migration database for running. + */ + protected function prepareDatabase(): void + { + if (! $this->migrator->repositoryExists()) { + $this->components->info('Preparing database.'); + + $this->components->task('Creating migration table', function () { + return $this->callSilent('migrate:install', array_filter([ + '--database' => $this->option('database'), + ])) == 0; + }); + + $this->newLine(); + } + + if (! $this->migrator->hasRunAnyMigrations() && ! $this->option('pretend')) { + $this->loadSchemaState(); + } + } + + /** + * Load the schema state to seed the initial database schema structure. + */ + protected function loadSchemaState(): void + { + $connection = $this->migrator->resolveConnection($this->option('database')); + + // First, we will make sure that the connection supports schema loading and that + // the schema file exists before we proceed any further. If not, we will just + // continue with the standard migration operation as normal without errors. + if (! is_file($path = $this->schemaPath($connection))) { + return; + } + + $this->components->info('Loading stored database schemas.'); + + $this->components->task($path, function () use ($connection, $path) { + // Since the schema file will create the "migrations" table and reload it to its + // proper state, we need to delete it here so we don't get an error that this + // table already exists when the stored database schema file gets executed. + $this->migrator->deleteRepository(); + + $connection->getSchemaState()->handleOutputUsing(function ($type, $buffer) { + $this->output->write($buffer); + })->load($path); + }); + + $this->newLine(); + + // Finally, we will fire an event that this schema has been loaded so developers + // can perform any post schema load tasks that are necessary in listeners for + // this event, which may seed the database tables with some necessary data. + $this->dispatcher->dispatch( + new SchemaLoaded($connection, $path) + ); + } + + /** + * Get the path to the stored schema for the given connection. + */ + protected function schemaPath(Connection $connection): string + { + if ($this->option('schema-path')) { + return $this->option('schema-path'); + } + + if (file_exists($path = database_path('schema/' . $connection->getName() . '-schema.dump'))) { + return $path; + } + + return database_path('schema/' . $connection->getName() . '-schema.sql'); + } +} diff --git a/src/database/src/Console/Migrations/RefreshCommand.php b/src/database/src/Console/Migrations/RefreshCommand.php new file mode 100644 index 000000000..f63e435c3 --- /dev/null +++ b/src/database/src/Console/Migrations/RefreshCommand.php @@ -0,0 +1,126 @@ +confirmToProceed()) { + return self::FAILURE; + } + + // Next we'll gather some of the options so that we can have the right options + // to pass to the commands. This includes options such as which database to + // use and the path to use for the migration. Then we'll run the command. + $database = $this->option('database'); + $path = $this->option('path'); + + // If the "step" option is specified it means we only want to rollback a small + // number of migrations before migrating again. For example, the user might + // only rollback and remigrate the latest four migrations instead of all. + $step = $this->option('step') ?: 0; + + if ($step > 0) { + $this->runRollback($database, $path, (int) $step); + } else { + $this->runReset($database, $path); + } + + // The refresh command is essentially just a brief aggregate of a few other of + // the migration commands and just provides a convenient wrapper to execute + // them in succession. We'll also see if we need to re-seed the database. + $this->call('migrate', array_filter([ + '--database' => $database, + '--path' => $path, + '--realpath' => $this->option('realpath'), + '--force' => true, + ])); + + $this->dispatcher->dispatch( + new DatabaseRefreshed($database, $this->needsSeeding()) + ); + + if ($this->needsSeeding()) { + $this->runSeeder($database); + } + + return 0; + } + + /** + * Run the rollback command. + */ + protected function runRollback(?string $database, array|string|null $path, int $step): void + { + $this->call('migrate:rollback', array_filter([ + '--database' => $database, + '--path' => $path, + '--realpath' => $this->option('realpath'), + '--step' => $step, + '--force' => true, + ])); + } + + /** + * Run the reset command. + */ + protected function runReset(?string $database, array|string|null $path): void + { + $this->call('migrate:reset', array_filter([ + '--database' => $database, + '--path' => $path, + '--realpath' => $this->option('realpath'), + '--force' => true, + ])); + } + + /** + * Determine if the developer has requested database seeding. + */ + protected function needsSeeding(): bool + { + return $this->option('seed') || $this->option('seeder'); + } + + /** + * Run the database seeder command. + */ + protected function runSeeder(?string $database): void + { + $this->call('db:seed', array_filter([ + '--database' => $database, + '--class' => $this->option('seeder') ?: 'Database\Seeders\DatabaseSeeder', + '--force' => true, + ])); + } +} diff --git a/src/database/src/Console/Migrations/ResetCommand.php b/src/database/src/Console/Migrations/ResetCommand.php new file mode 100644 index 000000000..0c036485f --- /dev/null +++ b/src/database/src/Console/Migrations/ResetCommand.php @@ -0,0 +1,56 @@ +confirmToProceed()) { + return self::FAILURE; + } + + return $this->migrator->usingConnection($this->option('database'), function () { + // First, we'll make sure that the migration table actually exists before we + // start trying to rollback and re-run all of the migrations. If it's not + // present we'll just bail out with an info message for the developers. + if (! $this->migrator->repositoryExists()) { + $this->components->warn('Migration table not found.'); + + return self::FAILURE; + } + + $this->migrator->setOutput($this->output)->reset( + $this->getMigrationPaths(), + $this->option('pretend') + ); + + return self::SUCCESS; + }); + } +} diff --git a/src/database/src/Console/Migrations/RollbackCommand.php b/src/database/src/Console/Migrations/RollbackCommand.php new file mode 100644 index 000000000..5e107e19f --- /dev/null +++ b/src/database/src/Console/Migrations/RollbackCommand.php @@ -0,0 +1,53 @@ +confirmToProceed()) { + return self::FAILURE; + } + + $this->migrator->usingConnection($this->option('database'), function () { + $this->migrator->setOutput($this->output)->rollback( + $this->getMigrationPaths(), + [ + 'pretend' => $this->option('pretend'), + 'step' => (int) $this->option('step'), + 'batch' => (int) $this->option('batch'), + ] + ); + }); + + return 0; + } +} diff --git a/src/database/src/Console/Migrations/StatusCommand.php b/src/database/src/Console/Migrations/StatusCommand.php new file mode 100644 index 000000000..8baad5394 --- /dev/null +++ b/src/database/src/Console/Migrations/StatusCommand.php @@ -0,0 +1,99 @@ +migrator->usingConnection($this->option('database'), function () { + if (! $this->migrator->repositoryExists()) { + $this->components->error('Migration table not found.'); + + return 1; + } + + $ran = $this->migrator->getRepository()->getRan(); + $batches = $this->migrator->getRepository()->getMigrationBatches(); + + $migrations = $this->getStatusFor($ran, $batches) + ->when($this->option('pending') !== false, fn ($collection) => $collection->filter(function ($migration) { // @phpstan-ignore argument.type (when() callback type inference) + return (new Stringable($migration[1]))->contains('Pending'); + })); + + if (count($migrations) > 0) { + $this->newLine(); + + $this->components->twoColumnDetail('Migration name', 'Batch / Status'); + + $migrations->each( + fn ($migration) => $this->components->twoColumnDetail($migration[0], $migration[1]) + ); + + $this->newLine(); + } elseif ($this->option('pending') !== false) { + $this->components->info('No pending migrations'); + } else { + $this->components->info('No migrations found'); + } + + if ($this->option('pending') && $migrations->some(fn ($m) => (new Stringable($m[1]))->contains('Pending'))) { + return (int) $this->option('pending'); + } + + return 0; + }); + } + + /** + * Get the status for the given run migrations. + */ + protected function getStatusFor(array $ran, array $batches): Collection + { + return (new Collection($this->getAllMigrationFiles())) + ->map(function ($migration) use ($ran, $batches) { + $migrationName = $this->migrator->getMigrationName($migration); + + $status = in_array($migrationName, $ran) + ? 'Ran' + : 'Pending'; + + if (in_array($migrationName, $ran)) { + $status = '[' . $batches[$migrationName] . '] ' . $status; + } + + return [$migrationName, $status]; + }); + } + + /** + * Get an array of all of the migration files. + */ + protected function getAllMigrationFiles(): array + { + return $this->migrator->getMigrationFiles($this->getMigrationPaths()); + } +} diff --git a/src/database/src/Console/Migrations/TableGuesser.php b/src/database/src/Console/Migrations/TableGuesser.php new file mode 100644 index 000000000..df0b815a9 --- /dev/null +++ b/src/database/src/Console/Migrations/TableGuesser.php @@ -0,0 +1,40 @@ + Model::withoutEvents($callback); + } +} diff --git a/src/database/src/Console/WipeCommand.php b/src/database/src/Console/WipeCommand.php new file mode 100644 index 000000000..51bc4cafb --- /dev/null +++ b/src/database/src/Console/WipeCommand.php @@ -0,0 +1,90 @@ +isProhibited() || ! $this->confirmToProceed()) { + return self::FAILURE; + } + + $database = $this->option('database'); + + if ($this->option('drop-views')) { + $this->dropAllViews($database); + + $this->components->info('Dropped all views successfully.'); + } + + $this->dropAllTables($database); + + $this->components->info('Dropped all tables successfully.'); + + if ($this->option('drop-types')) { + $this->dropAllTypes($database); + + $this->components->info('Dropped all types successfully.'); + } + + return self::SUCCESS; + } + + /** + * Drop all of the database tables. + */ + protected function dropAllTables(?string $database): void + { + $this->db->connection($database) + ->getSchemaBuilder() + ->dropAllTables(); + } + + /** + * Drop all of the database views. + */ + protected function dropAllViews(?string $database): void + { + $this->db->connection($database) + ->getSchemaBuilder() + ->dropAllViews(); + } + + /** + * Drop all of the database types. + */ + protected function dropAllTypes(?string $database): void + { + $this->db->connection($database) + ->getSchemaBuilder() + ->dropAllTypes(); + } +} diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php new file mode 100755 index 000000000..8f63a0b59 --- /dev/null +++ b/src/database/src/DatabaseManager.php @@ -0,0 +1,488 @@ + + */ + protected array $connections = []; + + /** + * The dynamically configured (DB::build) connection configurations. + * + * @var array + */ + protected array $dynamicConnectionConfigurations = []; + + /** + * The custom connection resolvers. + * + * @var array + */ + protected array $extensions = []; + + /** + * The callback to be executed to reconnect to a database. + */ + protected Closure $reconnector; + + /** + * Create a new database manager instance. + */ + public function __construct( + protected ContainerContract $app, + protected ConnectionFactory $factory + ) { + $this->reconnector = function ($connection) { + $connection->setPdo( + $this->reconnect($connection->getNameWithReadWriteType())->getRawPdo() + ); + }; + } + + /** + * Get a database connection instance. + * + * Delegates to ConnectionResolver for pooled, per-coroutine connection management. + * Resolves the default connection name here (checking Context for usingConnection override) + * before passing to the resolver. + */ + public function connection(UnitEnum|string|null $name = null): ConnectionInterface + { + return $this->app->get(ConnectionResolverInterface::class) + ->connection(enum_value($name) ?? $this->getDefaultConnection()); + } + + /** + * Resolve a connection directly without using the connection pool. + * + * This method is used by SimpleConnectionResolver for testing and Capsule + * environments where connection pooling is not needed. It manages connections + * in the $connections array like Laravel's original DatabaseManager. + * + * @internal For use by SimpleConnectionResolver only + */ + public function resolveConnectionDirectly(string $name): ConnectionInterface + { + [$database, $type] = $this->parseConnectionName($name); + + if (! isset($this->connections[$name])) { + $this->connections[$name] = $this->configure( + $this->makeConnection($database), + $type + ); + + $this->dispatchConnectionEstablishedEvent($this->connections[$name]); + } + + return $this->connections[$name]; + } + + /** + * Build a database connection instance from the given configuration. + * + * @throws RuntimeException Always - dynamic connections not supported in Hypervel + */ + public function build(array $config): ConnectionInterface + { + throw new RuntimeException( + 'Dynamic database connections via DB::build() are not supported in Hypervel. ' + . 'Configure all connections in config/databases.php instead.' + ); + } + + /** + * Calculate the dynamic connection name for an on-demand connection based on its configuration. + */ + public static function calculateDynamicConnectionName(array $config): string + { + return 'dynamic_' . md5((new Collection($config))->map(function ($value, $key) { + return $key . (is_string($value) || is_int($value) ? $value : ''); + })->implode('')); + } + + /** + * Get a database connection instance from the given configuration. + * + * @throws RuntimeException Always - dynamic connections not supported in Hypervel + */ + public function connectUsing(string $name, array $config, bool $force = false): ConnectionInterface + { + throw new RuntimeException( + 'Dynamic database connections via DB::connectUsing() are not supported in Hypervel. ' + . 'Configure all connections in config/databases.php instead.' + ); + } + + /** + * Parse the connection into an array of the name and read / write type. + * + * @return array{0: string, 1: null|string} + */ + protected function parseConnectionName(string $name): array + { + return Str::endsWith($name, ['::read', '::write']) + ? explode('::', $name, 2) + : [$name, null]; + } + + /** + * Make the database connection instance. + */ + protected function makeConnection(string $name): Connection + { + $config = $this->configuration($name); + + // First we will check by the connection name to see if an extension has been + // registered specifically for that connection. If it has we will call the + // Closure and pass it the config allowing it to resolve the connection. + if (isset($this->extensions[$name])) { + return call_user_func($this->extensions[$name], $config, $name); + } + + // Next we will check to see if an extension has been registered for a driver + // and will call the Closure if so, which allows us to have a more generic + // resolver for the drivers themselves which applies to all connections. + if (isset($this->extensions[$driver = $config['driver']])) { + return call_user_func($this->extensions[$driver], $config, $name); + } + + return $this->factory->make($config, $name); + } + + /** + * Get the configuration for a connection. + * + * @throws InvalidArgumentException + */ + protected function configuration(string $name): array + { + $connections = $this->app['config']['database.connections']; + + $config = $this->dynamicConnectionConfigurations[$name] ?? Arr::get($connections, $name); + + if (is_null($config)) { + throw new InvalidArgumentException("Database connection [{$name}] not configured."); + } + + return (new ConfigurationUrlParser()) + ->parseConfiguration($config); + } + + /** + * Prepare the database connection instance. + */ + protected function configure(Connection $connection, ?string $type): Connection + { + $connection = $this->setPdoForType($connection, $type)->setReadWriteType($type); + + // First we'll set the fetch mode and a few other dependencies of the database + // connection. This method basically just configures and prepares it to get + // used by the application. Once we're finished we'll return it back out. + if ($this->app->bound('events')) { + $connection->setEventDispatcher($this->app['events']); + } + + if ($this->app->bound(DatabaseTransactionsManager::class)) { + $connection->setTransactionManager($this->app->get(DatabaseTransactionsManager::class)); + } + + // Here we'll set a reconnector callback. This reconnector can be any callable + // so we will set a Closure to reconnect from this manager with the name of + // the connection, which will allow us to reconnect from the connections. + $connection->setReconnector($this->reconnector); + + return $connection; + } + + /** + * Dispatch the ConnectionEstablished event if the event dispatcher is available. + */ + protected function dispatchConnectionEstablishedEvent(Connection $connection): void + { + if (! $this->app->bound('events')) { + return; + } + + $this->app['events']->dispatch( + new ConnectionEstablished($connection) + ); + } + + /** + * Prepare the read / write mode for database connection instance. + */ + protected function setPdoForType(Connection $connection, ?string $type = null): Connection + { + if ($type === 'read') { + $connection->setPdo($connection->getReadPdo()); + } elseif ($type === 'write') { + $connection->setReadPdo($connection->getPdo()); + } + + return $connection; + } + + /** + * Disconnect from the given database and flush its pool. + * + * In pooled mode, this disconnects the current coroutine's connection, + * clears its context key (so the next connection() call gets a fresh + * pooled connection), and flushes the pool. Use this when connection + * configuration has changed and you need to fully reset. + * + * Note: The current coroutine may briefly hold two pooled connections + * (the old one releases via defer at coroutine end). This is acceptable + * for purge's intended rare usage. + */ + public function purge(UnitEnum|string|null $name = null): void + { + $name = enum_value($name) ?: $this->getDefaultConnection(); + + // Disconnect current connection if any + $this->disconnect($name); + + // Clear context so next connection() gets a fresh pooled connection + $contextKey = $this->getConnectionContextKey($name); + Context::destroy($contextKey); + + // Flush the pool to honor config changes + if ($this->app->has(PoolFactory::class)) { + $this->app->get(PoolFactory::class)->flushPool($name); + } + } + + /** + * Disconnect from the given database. + * + * In pooled mode, this nulls the PDOs on the current coroutine's connection + * (if one exists), forcing a reconnect on the next query. Does not clear + * context or affect the pool - the connection is still released at coroutine end. + */ + public function disconnect(UnitEnum|string|null $name = null): void + { + $name = enum_value($name) ?: $this->getDefaultConnection(); + $contextKey = $this->getConnectionContextKey($name); + + // Only act if this coroutine already has a connection + $connection = Context::get($contextKey); + if ($connection instanceof Connection) { + $connection->disconnect(); + } + } + + /** + * Reconnect to the given database. + * + * In pooled mode, if this coroutine already has a connection, reconnects + * its PDOs and returns it. Otherwise gets a fresh connection from the pool. + */ + public function reconnect(UnitEnum|string|null $name = null): Connection + { + $name = enum_value($name) ?: $this->getDefaultConnection(); + $contextKey = $this->getConnectionContextKey($name); + + // If we already have a connection in this coroutine, reconnect it + $connection = Context::get($contextKey); + if ($connection instanceof Connection) { + $connection->reconnect(); + $this->dispatchConnectionEstablishedEvent($connection); + + return $connection; + } + + // Otherwise get a fresh one from the pool + // @phpstan-ignore return.type (connection() returns ConnectionInterface but concrete Connection in practice) + return $this->connection($name); + } + + /** + * Set the default database connection for the callback execution. + * + * Uses Context for coroutine-safe state management, ensuring concurrent + * requests don't interfere with each other's default connection. + */ + public function usingConnection(UnitEnum|string $name, callable $callback): mixed + { + $previous = Context::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); + + Context::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, enum_value($name)); + + try { + return $callback(); + } finally { + if ($previous === null) { + Context::destroy(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); + } else { + Context::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, $previous); + } + } + } + + /** + * Refresh the PDO connections on a given connection. + */ + protected function refreshPdoConnections(string $name): Connection + { + [$database, $type] = $this->parseConnectionName($name); + + $fresh = $this->configure( + $this->makeConnection($database), + $type + ); + + return $this->connections[$name] + ->setPdo($fresh->getRawPdo()) + ->setReadPdo($fresh->getRawReadPdo()); + } + + /** + * Get the default connection name. + * + * Checks Context first for per-coroutine override (from usingConnection()), + * then falls back to the global config default. + */ + public function getDefaultConnection(): string + { + return Context::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY) + ?? $this->app['config']['database.default']; + } + + /** + * Set the default connection name. + */ + public function setDefaultConnection(string $name): void + { + $this->app['config']['database.default'] = $name; + } + + /** + * Get the context key for storing a connection. + * + * Uses the same format as ConnectionResolver for consistency. + */ + protected function getConnectionContextKey(string $name): string + { + return sprintf('database.connection.%s', $name); + } + + /** + * Get all of the supported drivers. + * + * @return string[] + */ + public function supportedDrivers(): array + { + return ['mysql', 'mariadb', 'pgsql', 'sqlite']; + } + + /** + * Get all of the drivers that are actually available. + * + * @return string[] + */ + public function availableDrivers(): array + { + return array_intersect( + $this->supportedDrivers(), + PDO::getAvailableDrivers() + ); + } + + /** + * Register an extension connection resolver. + */ + public function extend(string $name, callable $resolver): void + { + $this->extensions[$name] = $resolver; + } + + /** + * Remove an extension connection resolver. + */ + public function forgetExtension(string $name): void + { + unset($this->extensions[$name]); + } + + /** + * Return all of the created connections. + * + * Note: In Hypervel's pooled connection mode, connections are stored + * per-coroutine in Context rather than in this array. This method + * returns an empty array in normal pooled operation. Use the pool + * infrastructure to inspect active connections if needed. + * + * @return array + */ + public function getConnections(): array + { + return $this->connections; + } + + /** + * Set the database reconnector callback. + */ + public function setReconnector(callable $reconnector): void + { + $this->reconnector = $reconnector; + } + + /** + * Set the application instance used by the manager. + */ + public function setApplication(Application $app): static + { + $this->app = $app; + + return $this; + } + + /** + * Dynamically pass methods to the default connection. + */ + public function __call(string $method, array $parameters): mixed + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + return $this->connection()->{$method}(...$parameters); + } +} diff --git a/src/database/src/DatabaseTransactionRecord.php b/src/database/src/DatabaseTransactionRecord.php new file mode 100755 index 000000000..b125600ee --- /dev/null +++ b/src/database/src/DatabaseTransactionRecord.php @@ -0,0 +1,103 @@ +connection = $connection; + $this->level = $level; + $this->parent = $parent; + } + + /** + * Register a callback to be executed after committing. + */ + public function addCallback(callable $callback): void + { + $this->callbacks[] = $callback; + } + + /** + * Register a callback to be executed after rollback. + */ + public function addCallbackForRollback(callable $callback): void + { + $this->callbacksForRollback[] = $callback; + } + + /** + * Execute all of the callbacks. + */ + public function executeCallbacks(): void + { + foreach ($this->callbacks as $callback) { + $callback(); + } + } + + /** + * Execute all of the callbacks for rollback. + */ + public function executeCallbacksForRollback(): void + { + foreach ($this->callbacksForRollback as $callback) { + $callback(); + } + } + + /** + * Get all of the callbacks. + * + * @return callable[] + */ + public function getCallbacks(): array + { + return $this->callbacks; + } + + /** + * Get all of the callbacks for rollback. + * + * @return callable[] + */ + public function getCallbacksForRollback(): array + { + return $this->callbacksForRollback; + } +} diff --git a/src/database/src/DatabaseTransactionsManager.php b/src/database/src/DatabaseTransactionsManager.php new file mode 100755 index 000000000..020b0af9a --- /dev/null +++ b/src/database/src/DatabaseTransactionsManager.php @@ -0,0 +1,306 @@ + + */ + protected function getCommittedTransactionsInternal(): Collection + { + return Context::get(self::CONTEXT_COMMITTED, new Collection()); + } + + /** + * Set committed transactions for the current coroutine. + * + * @param Collection $transactions + */ + protected function setCommittedTransactions(Collection $transactions): void + { + Context::set(self::CONTEXT_COMMITTED, $transactions); + } + + /** + * Get all pending transactions for the current coroutine. + * + * @return Collection + */ + protected function getPendingTransactionsInternal(): Collection + { + return Context::get(self::CONTEXT_PENDING, new Collection()); + } + + /** + * Set pending transactions for the current coroutine. + * + * @param Collection $transactions + */ + protected function setPendingTransactions(Collection $transactions): void + { + Context::set(self::CONTEXT_PENDING, $transactions); + } + + /** + * Get current transaction map for the current coroutine. + * + * @return array + */ + protected function getCurrentTransaction(): array + { + return Context::get(self::CONTEXT_CURRENT, []); + } + + /** + * Set current transaction for a connection. + */ + protected function setCurrentTransactionForConnection(string $connection, ?DatabaseTransactionRecord $transaction): void + { + $current = $this->getCurrentTransaction(); + $current[$connection] = $transaction; + Context::set(self::CONTEXT_CURRENT, $current); + } + + /** + * Get current transaction for a connection. + */ + protected function getCurrentTransactionForConnection(string $connection): ?DatabaseTransactionRecord + { + return $this->getCurrentTransaction()[$connection] ?? null; + } + + /** + * Start a new database transaction. + */ + public function begin(string $connection, int $level): void + { + $pending = $this->getPendingTransactionsInternal(); + + $newTransaction = new DatabaseTransactionRecord( + $connection, + $level, + $this->getCurrentTransactionForConnection($connection) + ); + + $pending->push($newTransaction); + $this->setPendingTransactions($pending); + $this->setCurrentTransactionForConnection($connection, $newTransaction); + } + + /** + * Commit the root database transaction and execute callbacks. + * + * @return Collection + */ + public function commit(string $connection, int $levelBeingCommitted, int $newTransactionLevel): Collection + { + $this->stageTransactions($connection, $levelBeingCommitted); + + $currentForConnection = $this->getCurrentTransactionForConnection($connection); + if ($currentForConnection !== null) { + $this->setCurrentTransactionForConnection($connection, $currentForConnection->parent); + } + + if (! $this->afterCommitCallbacksShouldBeExecuted($newTransactionLevel) + && $newTransactionLevel !== 0) { + return new Collection(); + } + + // Clear pending transactions for this connection at or above the committed level + $pending = $this->getPendingTransactionsInternal()->reject( + fn ($transaction) => $transaction->connection === $connection + && $transaction->level >= $levelBeingCommitted + )->values(); + $this->setPendingTransactions($pending); + + $committed = $this->getCommittedTransactionsInternal(); + [$forThisConnection, $forOtherConnections] = $committed->partition( + fn ($transaction) => $transaction->connection === $connection + ); + + $this->setCommittedTransactions($forOtherConnections->values()); + + $forThisConnection->map->executeCallbacks(); + + return $forThisConnection; + } + + /** + * Move relevant pending transactions to a committed state. + */ + public function stageTransactions(string $connection, int $levelBeingCommitted): void + { + $pending = $this->getPendingTransactionsInternal(); + $committed = $this->getCommittedTransactionsInternal(); + + $toStage = $pending->filter( + fn ($transaction) => $transaction->connection === $connection + && $transaction->level >= $levelBeingCommitted + ); + + $this->setCommittedTransactions($committed->merge($toStage)); + + $this->setPendingTransactions( + $pending->reject( + fn ($transaction) => $transaction->connection === $connection + && $transaction->level >= $levelBeingCommitted + ) + ); + } + + /** + * Rollback the active database transaction. + */ + public function rollback(string $connection, int $newTransactionLevel): void + { + if ($newTransactionLevel === 0) { + $this->removeAllTransactionsForConnection($connection); + } else { + $pending = $this->getPendingTransactionsInternal()->reject( + fn ($transaction) => $transaction->connection === $connection + && $transaction->level > $newTransactionLevel + )->values(); + $this->setPendingTransactions($pending); + + $currentForConnection = $this->getCurrentTransactionForConnection($connection); + if ($currentForConnection !== null) { + do { + $this->removeCommittedTransactionsThatAreChildrenOf($currentForConnection); + $currentForConnection->executeCallbacksForRollback(); + $currentForConnection = $currentForConnection->parent; + $this->setCurrentTransactionForConnection($connection, $currentForConnection); + } while ( + $currentForConnection !== null + && $currentForConnection->level > $newTransactionLevel + ); + } + } + } + + /** + * Remove all pending, completed, and current transactions for the given connection name. + */ + protected function removeAllTransactionsForConnection(string $connection): void + { + $currentForConnection = $this->getCurrentTransactionForConnection($connection); + + for ($current = $currentForConnection; $current !== null; $current = $current->parent) { + $current->executeCallbacksForRollback(); + } + + $this->setCurrentTransactionForConnection($connection, null); + + $this->setPendingTransactions( + $this->getPendingTransactionsInternal()->reject( + fn ($transaction) => $transaction->connection === $connection + )->values() + ); + + $this->setCommittedTransactions( + $this->getCommittedTransactionsInternal()->reject( + fn ($transaction) => $transaction->connection === $connection + )->values() + ); + } + + /** + * Remove all transactions that are children of the given transaction. + */ + protected function removeCommittedTransactionsThatAreChildrenOf(DatabaseTransactionRecord $transaction): void + { + $committed = $this->getCommittedTransactionsInternal(); + + [$removedTransactions, $remaining] = $committed->partition( + fn ($committed) => $committed->connection === $transaction->connection + && $committed->parent === $transaction + ); + + $this->setCommittedTransactions($remaining); + + // Recurse down children + $removedTransactions->each( + fn ($removed) => $this->removeCommittedTransactionsThatAreChildrenOf($removed) + ); + } + + /** + * Register a transaction callback. + */ + public function addCallback(callable $callback): void + { + if ($current = $this->callbackApplicableTransactions()->last()) { + $current->addCallback($callback); + return; + } + + $callback(); + } + + /** + * Register a callback for transaction rollback. + */ + public function addCallbackForRollback(callable $callback): void + { + if ($current = $this->callbackApplicableTransactions()->last()) { + $current->addCallbackForRollback($callback); + } + } + + /** + * Get the transactions that are applicable to callbacks. + * + * @return Collection + */ + public function callbackApplicableTransactions(): Collection + { + return $this->getPendingTransactionsInternal(); + } + + /** + * Determine if after commit callbacks should be executed for the given transaction level. + */ + public function afterCommitCallbacksShouldBeExecuted(int $level): bool + { + return $level === 0; + } + + /** + * Get all of the pending transactions. + * + * @return Collection + */ + public function getPendingTransactions(): Collection + { + return $this->getPendingTransactionsInternal(); + } + + /** + * Get all of the committed transactions. + * + * @return Collection + */ + public function getCommittedTransactions(): Collection + { + return $this->getCommittedTransactionsInternal(); + } +} diff --git a/src/database/src/DeadlockException.php b/src/database/src/DeadlockException.php new file mode 100644 index 000000000..932114573 --- /dev/null +++ b/src/database/src/DeadlockException.php @@ -0,0 +1,11 @@ +has(ConcurrencyErrorDetectorContract::class) + ? $container->get(ConcurrencyErrorDetectorContract::class) + : new ConcurrencyErrorDetector(); + + return $detector->causedByConcurrencyError($e); + } +} diff --git a/src/database/src/DetectsLostConnections.php b/src/database/src/DetectsLostConnections.php new file mode 100644 index 000000000..d2386af77 --- /dev/null +++ b/src/database/src/DetectsLostConnections.php @@ -0,0 +1,26 @@ +has(LostConnectionDetectorContract::class) + ? $container->get(LostConnectionDetectorContract::class) + : new LostConnectionDetector(); + + return $detector->causedByLostConnection($e); + } +} diff --git a/src/core/src/Database/Eloquent/Attributes/Boot.php b/src/database/src/Eloquent/Attributes/Boot.php similarity index 100% rename from src/core/src/Database/Eloquent/Attributes/Boot.php rename to src/database/src/Eloquent/Attributes/Boot.php diff --git a/src/database/src/Eloquent/Attributes/CollectedBy.php b/src/database/src/Eloquent/Attributes/CollectedBy.php new file mode 100644 index 000000000..7b4b3d5d4 --- /dev/null +++ b/src/database/src/Eloquent/Attributes/CollectedBy.php @@ -0,0 +1,20 @@ +> $collectionClass + */ + public function __construct(public string $collectionClass) + { + } +} diff --git a/src/core/src/Database/Eloquent/Attributes/Initialize.php b/src/database/src/Eloquent/Attributes/Initialize.php similarity index 100% rename from src/core/src/Database/Eloquent/Attributes/Initialize.php rename to src/database/src/Eloquent/Attributes/Initialize.php diff --git a/src/core/src/Database/Eloquent/Attributes/ObservedBy.php b/src/database/src/Eloquent/Attributes/ObservedBy.php similarity index 100% rename from src/core/src/Database/Eloquent/Attributes/ObservedBy.php rename to src/database/src/Eloquent/Attributes/ObservedBy.php diff --git a/src/core/src/Database/Eloquent/Attributes/Scope.php b/src/database/src/Eloquent/Attributes/Scope.php similarity index 100% rename from src/core/src/Database/Eloquent/Attributes/Scope.php rename to src/database/src/Eloquent/Attributes/Scope.php diff --git a/src/core/src/Database/Eloquent/Attributes/ScopedBy.php b/src/database/src/Eloquent/Attributes/ScopedBy.php similarity index 100% rename from src/core/src/Database/Eloquent/Attributes/ScopedBy.php rename to src/database/src/Eloquent/Attributes/ScopedBy.php diff --git a/src/core/src/Database/Eloquent/Attributes/UseEloquentBuilder.php b/src/database/src/Eloquent/Attributes/UseEloquentBuilder.php similarity index 100% rename from src/core/src/Database/Eloquent/Attributes/UseEloquentBuilder.php rename to src/database/src/Eloquent/Attributes/UseEloquentBuilder.php diff --git a/src/core/src/Database/Eloquent/Attributes/UseFactory.php b/src/database/src/Eloquent/Attributes/UseFactory.php similarity index 92% rename from src/core/src/Database/Eloquent/Attributes/UseFactory.php rename to src/database/src/Eloquent/Attributes/UseFactory.php index 0d53d5f0e..0485639e7 100644 --- a/src/core/src/Database/Eloquent/Attributes/UseFactory.php +++ b/src/database/src/Eloquent/Attributes/UseFactory.php @@ -29,10 +29,10 @@ class UseFactory /** * Create a new attribute instance. * - * @param class-string<\Hypervel\Database\Eloquent\Factories\Factory> $class + * @param class-string<\Hypervel\Database\Eloquent\Factories\Factory> $factoryClass */ public function __construct( - public string $class, + public string $factoryClass, ) { } } diff --git a/src/core/src/Database/Eloquent/Attributes/UsePolicy.php b/src/database/src/Eloquent/Attributes/UsePolicy.php similarity index 100% rename from src/core/src/Database/Eloquent/Attributes/UsePolicy.php rename to src/database/src/Eloquent/Attributes/UsePolicy.php diff --git a/src/core/src/Database/Eloquent/Attributes/UseResource.php b/src/database/src/Eloquent/Attributes/UseResource.php similarity index 100% rename from src/core/src/Database/Eloquent/Attributes/UseResource.php rename to src/database/src/Eloquent/Attributes/UseResource.php diff --git a/src/core/src/Database/Eloquent/Attributes/UseResourceCollection.php b/src/database/src/Eloquent/Attributes/UseResourceCollection.php similarity index 100% rename from src/core/src/Database/Eloquent/Attributes/UseResourceCollection.php rename to src/database/src/Eloquent/Attributes/UseResourceCollection.php diff --git a/src/core/src/Database/Eloquent/BroadcastableModelEventOccurred.php b/src/database/src/Eloquent/BroadcastableModelEventOccurred.php similarity index 95% rename from src/core/src/Database/Eloquent/BroadcastableModelEventOccurred.php rename to src/database/src/Eloquent/BroadcastableModelEventOccurred.php index 0688ac3e9..8561da6b5 100644 --- a/src/core/src/Database/Eloquent/BroadcastableModelEventOccurred.php +++ b/src/database/src/Eloquent/BroadcastableModelEventOccurred.php @@ -4,12 +4,11 @@ namespace Hypervel\Database\Eloquent; -use Hyperf\Collection\Collection; -use Hyperf\Database\Model\Model; -use Hypervel\Broadcasting\Contracts\ShouldBroadcast; use Hypervel\Broadcasting\InteractsWithSockets; use Hypervel\Broadcasting\PrivateChannel; +use Hypervel\Contracts\Broadcasting\ShouldBroadcast; use Hypervel\Queue\SerializesModels; +use Hypervel\Support\Collection; class BroadcastableModelEventOccurred implements ShouldBroadcast { diff --git a/src/core/src/Database/Eloquent/BroadcastsEvents.php b/src/database/src/Eloquent/BroadcastsEvents.php similarity index 55% rename from src/core/src/Database/Eloquent/BroadcastsEvents.php rename to src/database/src/Eloquent/BroadcastsEvents.php index 2ec05fb5b..f4976467c 100644 --- a/src/core/src/Database/Eloquent/BroadcastsEvents.php +++ b/src/database/src/Eloquent/BroadcastsEvents.php @@ -4,44 +4,51 @@ namespace Hypervel\Database\Eloquent; -use Hyperf\Collection\Arr; -use Hyperf\Context\ApplicationContext; use Hypervel\Broadcasting\Channel; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastFactory; -use Hypervel\Broadcasting\Contracts\HasBroadcastChannel; use Hypervel\Broadcasting\PendingBroadcast; - -use function Hyperf\Tappable\tap; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastFactory; +use Hypervel\Contracts\Broadcasting\HasBroadcastChannel; +use Hypervel\Support\Arr; trait BroadcastsEvents { - protected static $isBroadcasting = true; + /** + * Indicates if the model is currently broadcasting. + */ + protected static bool $isBroadcasting = true; /** * Boot the event broadcasting trait. */ public static function bootBroadcastsEvents(): void { - static::registerCallback( - 'created', - fn ($model) => $model->broadcastCreated() - ); + static::created(function ($model) { + $model->broadcastCreated(); + }); - static::registerCallback( - 'updated', - fn ($model) => $model->broadcastUpdated() - ); + static::updated(function ($model) { + $model->broadcastUpdated(); + }); - static::registerCallback( - 'deleted', - fn ($model) => $model->broadcastDeleted() - ); + if (method_exists(static::class, 'bootSoftDeletes')) { + static::softDeleted(function ($model) { + $model->broadcastTrashed(); + }); + + static::restored(function ($model) { + $model->broadcastRestored(); + }); + } + + static::deleted(function ($model) { + $model->broadcastDeleted(); + }); } /** * Broadcast that the model was created. */ - public function broadcastCreated(array|Channel|HasBroadcastChannel|null $channels = null): ?PendingBroadcast + public function broadcastCreated(Channel|HasBroadcastChannel|array|null $channels = null): ?PendingBroadcast { return $this->broadcastIfBroadcastChannelsExistForEvent( $this->newBroadcastableModelEvent('created'), @@ -53,7 +60,7 @@ public function broadcastCreated(array|Channel|HasBroadcastChannel|null $channel /** * Broadcast that the model was updated. */ - public function broadcastUpdated(array|Channel|HasBroadcastChannel|null $channels = null): ?PendingBroadcast + public function broadcastUpdated(Channel|HasBroadcastChannel|array|null $channels = null): ?PendingBroadcast { return $this->broadcastIfBroadcastChannelsExistForEvent( $this->newBroadcastableModelEvent('updated'), @@ -62,10 +69,34 @@ public function broadcastUpdated(array|Channel|HasBroadcastChannel|null $channel ); } + /** + * Broadcast that the model was trashed. + */ + public function broadcastTrashed(Channel|HasBroadcastChannel|array|null $channels = null): ?PendingBroadcast + { + return $this->broadcastIfBroadcastChannelsExistForEvent( + $this->newBroadcastableModelEvent('trashed'), + 'trashed', + $channels + ); + } + + /** + * Broadcast that the model was restored. + */ + public function broadcastRestored(Channel|HasBroadcastChannel|array|null $channels = null): ?PendingBroadcast + { + return $this->broadcastIfBroadcastChannelsExistForEvent( + $this->newBroadcastableModelEvent('restored'), + 'restored', + $channels + ); + } + /** * Broadcast that the model was deleted. */ - public function broadcastDeleted(array|Channel|HasBroadcastChannel|null $channels = null): ?PendingBroadcast + public function broadcastDeleted(Channel|HasBroadcastChannel|array|null $channels = null): ?PendingBroadcast { return $this->broadcastIfBroadcastChannelsExistForEvent( $this->newBroadcastableModelEvent('deleted'), @@ -77,16 +108,17 @@ public function broadcastDeleted(array|Channel|HasBroadcastChannel|null $channel /** * Broadcast the given event instance if channels are configured for the model event. */ - protected function broadcastIfBroadcastChannelsExistForEvent(mixed $instance, string $event, mixed $channels = null): ?PendingBroadcast - { - if (! static::$isBroadcasting) { + protected function broadcastIfBroadcastChannelsExistForEvent( + BroadcastableModelEventOccurred $instance, + string $event, + Channel|HasBroadcastChannel|array|null $channels = null, + ): ?PendingBroadcast { + if (! static::isBroadcasting()) { return null; } if (! empty($this->broadcastOn($event)) || ! empty($channels)) { - return ApplicationContext::getContainer() - ->get(BroadcastFactory::class) - ->event($instance->onChannels(Arr::wrap($channels))); + return app(BroadcastFactory::class)->event($instance->onChannels(Arr::wrap($channels))); } return null; @@ -95,7 +127,7 @@ protected function broadcastIfBroadcastChannelsExistForEvent(mixed $instance, st /** * Create a new broadcastable model event event. */ - public function newBroadcastableModelEvent(string $event): mixed + public function newBroadcastableModelEvent(string $event): BroadcastableModelEventOccurred { return tap($this->newBroadcastableEvent($event), function ($event) { $event->connection = property_exists($this, 'broadcastConnection') @@ -123,7 +155,7 @@ protected function newBroadcastableEvent(string $event): BroadcastableModelEvent /** * Get the channels that model events should broadcast on. */ - public function broadcastOn(string $event): array|Channel + public function broadcastOn(string $event): Channel|array { return [$this]; } diff --git a/src/database/src/Eloquent/BroadcastsEventsAfterCommit.php b/src/database/src/Eloquent/BroadcastsEventsAfterCommit.php new file mode 100644 index 000000000..cf8bf10d2 --- /dev/null +++ b/src/database/src/Eloquent/BroadcastsEventsAfterCommit.php @@ -0,0 +1,20 @@ + */ + use BuildsQueries, ForwardsCalls, QueriesRelationships { + BuildsQueries::sole as baseSole; + } + + /** + * The base query builder instance. + * + * @var \Hypervel\Database\Query\Builder + */ + protected $query; + + /** + * The model being queried. + * + * @var TModel + */ + protected $model; + + /** + * The attributes that should be added to new models created by this builder. + * + * @var array + */ + public $pendingAttributes = []; + + /** + * The relationships that should be eager loaded. + * + * @var array + */ + protected $eagerLoad = []; + + /** + * All of the globally registered builder macros. + * + * @var array + */ + protected static $macros = []; + + /** + * All of the locally registered builder macros. + * + * @var array + */ + protected $localMacros = []; + + /** + * A replacement for the typical delete function. + * + * @var null|Closure + */ + protected $onDelete; + + /** + * The properties that should be returned from query builder. + * + * @var string[] + */ + protected $propertyPassthru = [ + 'from', + ]; + + /** + * The methods that should be returned from query builder. + * + * @var string[] + */ + protected $passthru = [ + 'aggregate', + 'average', + 'avg', + 'count', + 'dd', + 'ddrawsql', + 'doesntexist', + 'doesntexistor', + 'dump', + 'dumprawsql', + 'exists', + 'existsor', + 'explain', + 'getbindings', + 'getconnection', + 'getcountforpagination', + 'getgrammar', + 'getrawbindings', + 'implode', + 'insert', + 'insertgetid', + 'insertorignore', + 'insertusing', + 'insertorignoreusing', + 'max', + 'min', + 'numericaggregate', + 'raw', + 'rawvalue', + 'sum', + 'tosql', + 'torawsql', + ]; + + /** + * Applied global scopes. + * + * @var array + */ + protected $scopes = []; + + /** + * Removed global scopes. + * + * @var array + */ + protected $removedScopes = []; + + /** + * The callbacks that should be invoked after retrieving data from the database. + * + * @var array + */ + protected $afterQueryCallbacks = []; + + /** + * The callbacks that should be invoked on clone. + * + * @var array + */ + protected $onCloneCallbacks = []; + + /** + * Create a new Eloquent query builder instance. + */ + public function __construct(QueryBuilder $query) + { + $this->query = $query; + } + + /** + * Create and return an un-saved model instance. + * + * @return TModel + */ + public function make(array $attributes = []) + { + return $this->newModelInstance($attributes); + } + + /** + * Register a new global scope. + * + * @param string $identifier + * @param Closure|\Hypervel\Database\Eloquent\Scope $scope + * @return $this + */ + public function withGlobalScope($identifier, $scope) + { + $this->scopes[$identifier] = $scope; + + if (method_exists($scope, 'extend')) { + $scope->extend($this); + } + + return $this; + } + + /** + * Remove a registered global scope. + * + * @param \Hypervel\Database\Eloquent\Scope|string $scope + * @return $this + */ + public function withoutGlobalScope($scope) + { + if (! is_string($scope)) { + $scope = get_class($scope); + } + + unset($this->scopes[$scope]); + + $this->removedScopes[] = $scope; + + return $this; + } + + /** + * Remove all or passed registered global scopes. + * + * @return $this + */ + public function withoutGlobalScopes(?array $scopes = null) + { + if (! is_array($scopes)) { + $scopes = array_keys($this->scopes); + } + + foreach ($scopes as $scope) { + $this->withoutGlobalScope($scope); + } + + return $this; + } + + /** + * Remove all global scopes except the given scopes. + * + * @return $this + */ + public function withoutGlobalScopesExcept(array $scopes = []) + { + $this->withoutGlobalScopes( + array_diff(array_keys($this->scopes), $scopes) + ); + + return $this; + } + + /** + * Get an array of global scopes that were removed from the query. + * + * @return array + */ + public function removedScopes() + { + return $this->removedScopes; + } + + /** + * Add a where clause on the primary key to the query. + * + * @param mixed $id + * @return $this + */ + public function whereKey($id) + { + if ($id instanceof Model) { + $id = $id->getKey(); + } + + if (is_array($id) || $id instanceof Arrayable) { + if (in_array($this->model->getKeyType(), ['int', 'integer'])) { + $this->query->whereIntegerInRaw($this->model->getQualifiedKeyName(), $id); + } else { + $this->query->whereIn($this->model->getQualifiedKeyName(), $id); + } + + return $this; + } + + if ($id !== null && $this->model->getKeyType() === 'string') { + $id = (string) $id; + } + + return $this->where($this->model->getQualifiedKeyName(), '=', $id); + } + + /** + * Add a where clause on the primary key to the query. + * + * @param mixed $id + * @return $this + */ + public function whereKeyNot($id) + { + if ($id instanceof Model) { + $id = $id->getKey(); + } + + if (is_array($id) || $id instanceof Arrayable) { + if (in_array($this->model->getKeyType(), ['int', 'integer'])) { + $this->query->whereIntegerNotInRaw($this->model->getQualifiedKeyName(), $id); + } else { + $this->query->whereNotIn($this->model->getQualifiedKeyName(), $id); + } + + return $this; + } + + if ($id !== null && $this->model->getKeyType() === 'string') { + $id = (string) $id; + } + + return $this->where($this->model->getQualifiedKeyName(), '!=', $id); + } + + /** + * Exclude the given models from the query results. + * + * @param iterable|mixed $models + * @return static + */ + public function except($models) + { + return $this->whereKeyNot( + $models instanceof Model + ? $models->getKey() + : Collection::wrap($models)->modelKeys() + ); + } + + /** + * Add a basic where clause to the query. + * + * @param array|(Closure(static): mixed)|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function where($column, $operator = null, $value = null, $boolean = 'and') + { + if ($column instanceof Closure && is_null($operator)) { + // @phpstan-ignore argument.type (closure receives Builder instance, static type not required) + $column($query = $this->model->newQueryWithoutRelationships()); + + $this->eagerLoad = array_merge($this->eagerLoad, $query->getEagerLoads()); + + $this->query->addNestedWhereQuery($query->getQuery(), $boolean); + } else { + $this->query->where(...func_get_args()); + } + + return $this; + } + + /** + * Add a basic where clause to the query, and return the first result. + * + * @param array|(Closure(static): mixed)|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return null|TModel + */ + public function firstWhere($column, $operator = null, $value = null, $boolean = 'and') + { + return $this->where(...func_get_args())->first(); + } + + /** + * Add an "or where" clause to the query. + * + * @param array|(Closure(static): mixed)|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhere($column, $operator = null, $value = null) + { + [$value, $operator] = $this->query->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + return $this->where($column, $operator, $value, 'or'); + } + + /** + * Add a basic "where not" clause to the query. + * + * @param array|(Closure(static): mixed)|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function whereNot($column, $operator = null, $value = null, $boolean = 'and') + { + return $this->where($column, $operator, $value, $boolean . ' not'); + } + + /** + * Add an "or where not" clause to the query. + * + * @param array|(Closure(static): mixed)|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereNot($column, $operator = null, $value = null) + { + return $this->whereNot($column, $operator, $value, 'or'); + } + + /** + * Add an "order by" clause for a timestamp to the query. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function latest($column = null) + { + if (is_null($column)) { + $column = $this->model->getCreatedAtColumn() ?? 'created_at'; + } + + $this->query->latest($column); + + return $this; + } + + /** + * Add an "order by" clause for a timestamp to the query. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function oldest($column = null) + { + if (is_null($column)) { + $column = $this->model->getCreatedAtColumn() ?? 'created_at'; + } + + $this->query->oldest($column); + + return $this; + } + + /** + * Create a collection of models from plain arrays. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + public function hydrate(array $items) + { + $instance = $this->newModelInstance(); + + return $instance->newCollection(array_map(function ($item) use ($items, $instance) { + $model = $instance->newFromBuilder($item); + + if (count($items) > 1) { + $model->preventsLazyLoading = Model::preventsLazyLoading(); + } + + return $model; + }, $items)); + } + + /** + * Insert into the database after merging the model's default attributes, setting timestamps, and casting values. + * + * @param array> $values + * @return bool + */ + public function fillAndInsert(array $values) + { + return $this->insert($this->fillForInsert($values)); + } + + /** + * Insert (ignoring errors) into the database after merging the model's default attributes, setting timestamps, and casting values. + * + * @param array> $values + * @return int + */ + public function fillAndInsertOrIgnore(array $values) + { + return $this->insertOrIgnore($this->fillForInsert($values)); + } + + /** + * Insert a record into the database and get its ID after merging the model's default attributes, setting timestamps, and casting values. + * + * @param array $values + * @return int + */ + public function fillAndInsertGetId(array $values) + { + return $this->insertGetId($this->fillForInsert([$values])[0]); + } + + /** + * Enrich the given values by merging in the model's default attributes, adding timestamps, and casting values. + * + * @param array> $values + * @return array> + */ + public function fillForInsert(array $values) + { + if (empty($values)) { + return []; + } + + if (! is_array(Arr::first($values))) { + $values = [$values]; + } + + $this->model->unguarded(function () use (&$values) { + foreach ($values as $key => $rowValues) { + $values[$key] = tap( + $this->newModelInstance($rowValues), + fn ($model) => $model->setUniqueIds() + )->getAttributes(); + } + }); + + return $this->addTimestampsToUpsertValues($values); + } + + /** + * Create a collection of models from a raw query. + * + * @param string $query + * @param array $bindings + * @return \Hypervel\Database\Eloquent\Collection + */ + public function fromQuery($query, $bindings = []) + { + return $this->hydrate( + $this->query->getConnection()->select($query, $bindings) + ); + } + + /** + * Find a model by its primary key. + * + * @param mixed $id + * @param array|string $columns + * @return ($id is (array|\Hypervel\Contracts\Support\Arrayable) ? \Hypervel\Database\Eloquent\Collection : null|TModel) + */ + public function find($id, $columns = ['*']) + { + if (is_array($id) || $id instanceof Arrayable) { + return $this->findMany($id, $columns); + } + + return $this->whereKey($id)->first($columns); + } + + /** + * Find a sole model by its primary key. + * + * @param mixed $id + * @param array|string $columns + * @return TModel + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function findSole($id, $columns = ['*']) + { + return $this->whereKey($id)->sole($columns); + } + + /** + * Find multiple models by their primary keys. + * + * @param array|\Hypervel\Contracts\Support\Arrayable $ids + * @param array|string $columns + * @return \Hypervel\Database\Eloquent\Collection + */ + public function findMany($ids, $columns = ['*']) + { + $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; + + if (empty($ids)) { + return $this->model->newCollection(); + } + + return $this->whereKey($ids)->get($columns); + } + + /** + * Find a model by its primary key or throw an exception. + * + * @param mixed $id + * @param array|string $columns + * @return ($id is (array|\Hypervel\Contracts\Support\Arrayable) ? \Hypervel\Database\Eloquent\Collection : TModel) + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + */ + public function findOrFail($id, $columns = ['*']) + { + $result = $this->find($id, $columns); + + $id = $id instanceof Arrayable ? $id->toArray() : $id; + + if (is_array($id)) { + if (count($result) !== count(array_unique($id))) { + throw (new ModelNotFoundException())->setModel( + get_class($this->model), + array_diff($id, $result->modelKeys()) + ); + } + + return $result; + } + + if (is_null($result)) { + throw (new ModelNotFoundException())->setModel( + get_class($this->model), + $id + ); + } + + return $result; + } + + /** + * Find a model by its primary key or return fresh model instance. + * + * @param mixed $id + * @param array|string $columns + * @return ($id is (array|\Hypervel\Contracts\Support\Arrayable) ? \Hypervel\Database\Eloquent\Collection : TModel) + */ + public function findOrNew($id, $columns = ['*']) + { + if (! is_null($model = $this->find($id, $columns))) { + return $model; + } + + return $this->newModelInstance(); + } + + /** + * Find a model by its primary key or call a callback. + * + * @template TValue + * + * @param mixed $id + * @param (Closure(): TValue)|list|string $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is (\Hypervel\Contracts\Support\Arrayable|array) + * ? \Hypervel\Database\Eloquent\Collection + * : TModel|TValue + * ) + */ + public function findOr($id, $columns = ['*'], ?Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->find($id, $columns))) { + return $model; + } + + return $callback(); + } + + /** + * Get the first record matching the attributes or instantiate it. + * + * @return TModel + */ + public function firstOrNew(array $attributes = [], array $values = []) + { + if (! is_null($instance = $this->where($attributes)->first())) { + return $instance; + } + + return $this->newModelInstance(array_merge($attributes, $values)); + } + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @return TModel + */ + public function firstOrCreate(array $attributes = [], array $values = []) + { + if (! is_null($instance = (clone $this)->where($attributes)->first())) { + return $instance; + } + + return $this->createOrFirst($attributes, $values); + } + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @return TModel + */ + public function createOrFirst(array $attributes = [], array $values = []) + { + try { + return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values))); + } catch (UniqueConstraintViolationException $e) { + // @phpstan-ignore return.type (first() returns hydrated TModel, not stdClass) + return $this->useWritePdo()->where($attributes)->first() ?? throw $e; + } + } + + /** + * Create or update a record matching the attributes, and fill it with values. + * + * @return TModel + */ + public function updateOrCreate(array $attributes, array $values = []) + { + return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values)->save(); + } + }); + } + + /** + * Create a record matching the attributes, or increment the existing record. + * + * @param float|int $default + * @param float|int $step + * @return TModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', $default = 1, $step = 1, array $extra = []) + { + return tap($this->firstOrCreate($attributes, [$column => $default]), function ($instance) use ($column, $step, $extra) { + if (! $instance->wasRecentlyCreated) { + $instance->increment($column, $step, $extra); + } + }); + } + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array|string $columns + * @return TModel + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + */ + public function firstOrFail($columns = ['*']) + { + if (! is_null($model = $this->first($columns))) { + return $model; + } + + throw (new ModelNotFoundException())->setModel(get_class($this->model)); + } + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list $columns + * @param null|(Closure(): TValue) $callback + * @return TModel|TValue + */ + public function firstOr($columns = ['*'], ?Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->first($columns))) { + return $model; + } + + return $callback(); + } + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @param array|string $columns + * @return TModel + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function sole($columns = ['*']) + { + try { + return $this->baseSole($columns); + } catch (RecordsNotFoundException) { + throw (new ModelNotFoundException())->setModel(get_class($this->model)); + } + } + + /** + * Get a single column's value from the first result of a query. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return mixed + */ + public function value($column) + { + if ($result = $this->first([$column])) { + $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + + return $result->{Str::afterLast($column, '.')}; + } + } + + /** + * Get a single column's value from the first result of a query if it's the sole matching record. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return mixed + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function soleValue($column) + { + $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + + return $this->sole([$column])->{Str::afterLast($column, '.')}; + } + + /** + * Get a single column's value from the first result of the query or throw an exception. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return mixed + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + */ + public function valueOrFail($column) + { + $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + + return $this->firstOrFail([$column])->{Str::afterLast($column, '.')}; + } + + /** + * Execute the query as a "select" statement. + * + * @param array|string $columns + * @return \Hypervel\Database\Eloquent\Collection + */ + public function get($columns = ['*']) + { + $builder = $this->applyScopes(); + + // If we actually found models we will also eager load any relationships that + // have been specified as needing to be eager loaded, which will solve the + // n+1 query issue for the developers to avoid running a lot of queries. + if (count($models = $builder->getModels($columns)) > 0) { + $models = $builder->eagerLoadRelations($models); + } + + return $this->applyAfterQueryCallbacks( + $builder->getModel()->newCollection($models) + ); + } + + /** + * Get the hydrated models without eager loading. + * + * @param array|string $columns + * @return array + */ + public function getModels($columns = ['*']) + { + return $this->model->hydrate( + $this->query->get($columns)->all() + )->all(); + } + + /** + * Eager load the relationships for the models. + * + * @param array $models + * @return array + */ + public function eagerLoadRelations(array $models) + { + foreach ($this->eagerLoad as $name => $constraints) { + // For nested eager loads we'll skip loading them here and they will be set as an + // eager load on the query to retrieve the relation so that they will be eager + // loaded on that query, because that is where they get hydrated as models. + if (! str_contains($name, '.')) { + $models = $this->eagerLoadRelation($models, $name, $constraints); + } + } + + return $models; + } + + /** + * Eagerly load the relationship on a set of models. + * + * @param string $name + * @return array + */ + protected function eagerLoadRelation(array $models, $name, Closure $constraints) + { + // First we will "back up" the existing where conditions on the query so we can + // add our eager constraints. Then we will merge the wheres that were on the + // query back to it in order that any where conditions might be specified. + $relation = $this->getRelation($name); + + $relation->addEagerConstraints($models); + + $constraints($relation); + + // Once we have the results, we just match those back up to their parent models + // using the relationship instance. Then we just return the finished arrays + // of models which have been eagerly hydrated and are readied for return. + return $relation->match( + $relation->initRelation($models, $name), + $relation->getEager(), + $name + ); + } + + /** + * Get the relation instance for the given relation name. + * + * @param string $name + * @return \Hypervel\Database\Eloquent\Relations\Relation<\Hypervel\Database\Eloquent\Model, TModel, *> + */ + public function getRelation($name) + { + // We want to run a relationship query without any constrains so that we will + // not have to remove these where clauses manually which gets really hacky + // and error prone. We don't want constraints because we add eager ones. + $relation = Relation::noConstraints(function () use ($name) { + try { + return $this->getModel()->newInstance()->{$name}(); + } catch (BadMethodCallException) { + throw RelationNotFoundException::make($this->getModel(), $name); + } + }); + + $nested = $this->relationsNestedUnder($name); + + // If there are nested relationships set on the query, we will put those onto + // the query instances so that they can be handled after this relationship + // is loaded. In this way they will all trickle down as they are loaded. + if (count($nested) > 0) { + $relation->getQuery()->with($nested); + } + + return $relation; + } + + /** + * Get the deeply nested relations for a given top-level relation. + * + * @param string $relation + * @return array + */ + protected function relationsNestedUnder($relation) + { + $nested = []; + + // We are basically looking for any relationships that are nested deeper than + // the given top-level relationship. We will just check for any relations + // that start with the given top relations and adds them to our arrays. + foreach ($this->eagerLoad as $name => $constraints) { + if ($this->isNestedUnder($relation, $name)) { + $nested[substr($name, strlen($relation . '.'))] = $constraints; + } + } + + return $nested; + } + + /** + * Determine if the relationship is nested. + * + * @param string $relation + * @param string $name + * @return bool + */ + protected function isNestedUnder($relation, $name) + { + return str_contains($name, '.') && str_starts_with($name, $relation . '.'); + } + + /** + * Register a closure to be invoked after the query is executed. + * + * @return $this + */ + public function afterQuery(Closure $callback) + { + $this->afterQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "after query" modification callbacks. + * + * @param mixed $result + * @return mixed + */ + public function applyAfterQueryCallbacks($result) + { + foreach ($this->afterQueryCallbacks as $afterQueryCallback) { + $result = $afterQueryCallback($result) ?: $result; + } + + return $result; + } + + /** + * Get a lazy collection for the given query. + * + * @return \Hypervel\Support\LazyCollection + */ + public function cursor() + { + return $this->applyScopes()->query->cursor()->map(function ($record) { + $model = $this->newModelInstance()->newFromBuilder($record); + + return $this->applyAfterQueryCallbacks($this->newModelInstance()->newCollection([$model]))->first(); + })->reject(fn ($model) => is_null($model)); + } + + /** + * Add a generic "order by" clause if the query doesn't already have one. + */ + protected function enforceOrderBy() + { + if (empty($this->query->orders) && empty($this->query->unionOrders)) { + $this->orderBy($this->model->getQualifiedKeyName(), 'asc'); + } + } + + /** + * Get a collection with the values of a given column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @param null|string $key + * @return \Hypervel\Support\Collection + */ + public function pluck($column, $key = null) + { + $results = $this->toBase()->pluck($column, $key); + + $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + + $column = Str::after($column, "{$this->model->getTable()}."); + + // If the model has a mutator for the requested column, we will spin through + // the results and mutate the values so that the mutated version of these + // columns are returned as you would expect from these Eloquent models. + if (! $this->model->hasAnyGetMutator($column) + && ! $this->model->hasCast($column) + && ! in_array($column, $this->model->getDates())) { + return $this->applyAfterQueryCallbacks($results); + } + + return $this->applyAfterQueryCallbacks( + $results->map(function ($value) use ($column) { + return $this->model->newFromBuilder([$column => $value])->{$column}; + }) + ); + } + + /** + * Paginate the given query. + * + * @param null|Closure|int $perPage + * @param array|string $columns + * @param string $pageName + * @param null|int $page + * @param null|Closure|int $total + * @return \Hypervel\Pagination\LengthAwarePaginator + * + * @throws InvalidArgumentException + */ + public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null) + { + $page = $page ?: Paginator::resolveCurrentPage($pageName); + + $total = value($total) ?? $this->toBase()->getCountForPagination(); + + $perPage = value($perPage, $total) ?: $this->model->getPerPage(); + + $results = $total + ? $this->forPage($page, $perPage)->get($columns) + : $this->model->newCollection(); + + return $this->paginator($results, $total, $perPage, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ]); + } + + /** + * Paginate the given query into a simple paginator. + * + * @param null|int $perPage + * @param array|string $columns + * @param string $pageName + * @param null|int $page + * @return \Hypervel\Contracts\Pagination\Paginator + */ + public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) + { + $page = $page ?: Paginator::resolveCurrentPage($pageName); + + $perPage = $perPage ?: $this->model->getPerPage(); + + // Next we will set the limit and offset for this query so that when we get the + // results we get the proper section of results. Then, we'll create the full + // paginator instances for these results with the given page and per page. + $this->offset(($page - 1) * $perPage)->limit($perPage + 1); + + return $this->simplePaginator($this->get($columns), $perPage, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ]); + } + + /** + * Paginate the given query into a cursor paginator. + * + * @param null|int $perPage + * @param array|string $columns + * @param string $cursorName + * @param null|\Hypervel\Pagination\Cursor|string $cursor + * @return \Hypervel\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + $perPage = $perPage ?: $this->model->getPerPage(); + + return $this->paginateUsingCursor($perPage, $columns, $cursorName, $cursor); + } + + /** + * Ensure the proper order by required for cursor pagination. + * + * @param bool $shouldReverse + * @return \Hypervel\Support\Collection + */ + protected function ensureOrderForCursorPagination($shouldReverse = false) + { + if (empty($this->query->orders) && empty($this->query->unionOrders)) { + $this->enforceOrderBy(); + } + + $reverseDirection = function ($order) { + if (! isset($order['direction'])) { + return $order; + } + + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; + + return $order; + }; + + if ($shouldReverse) { + $this->query->orders = (new BaseCollection($this->query->orders))->map($reverseDirection)->toArray(); + $this->query->unionOrders = (new BaseCollection($this->query->unionOrders))->map($reverseDirection)->toArray(); + } + + $orders = ! empty($this->query->unionOrders) ? $this->query->unionOrders : $this->query->orders; + + return (new BaseCollection($orders)) + ->filter(fn ($order) => Arr::has($order, 'direction')) + ->values(); + } + + /** + * Save a new model and return the instance. + * + * @return TModel + */ + public function create(array $attributes = []) + { + return tap($this->newModelInstance($attributes), function ($instance) { + $instance->save(); + }); + } + + /** + * Save a new model and return the instance without raising model events. + * + * @return TModel + */ + public function createQuietly(array $attributes = []) + { + return Model::withoutEvents(fn () => $this->create($attributes)); + } + + /** + * Save a new model and return the instance. Allow mass-assignment. + * + * @return TModel + */ + public function forceCreate(array $attributes) + { + return $this->model->unguarded(function () use ($attributes) { + return $this->newModelInstance()->create($attributes); + }); + } + + /** + * Save a new model instance with mass assignment without raising model events. + * + * @return TModel + */ + public function forceCreateQuietly(array $attributes = []) + { + return Model::withoutEvents(fn () => $this->forceCreate($attributes)); + } + + /** + * Update records in the database. + * + * @return int + */ + public function update(array $values) + { + return $this->toBase()->update($this->addUpdatedAtColumn($values)); + } + + /** + * Insert new records or update the existing ones. + * + * @param array|string $uniqueBy + * @param null|array $update + * @return int + */ + public function upsert(array $values, $uniqueBy, $update = null) + { + if (empty($values)) { + return 0; + } + + if (! is_array(Arr::first($values))) { + $values = [$values]; + } + + if (is_null($update)) { + $update = array_keys(Arr::first($values)); + } + + return $this->toBase()->upsert( + $this->addTimestampsToUpsertValues($this->addUniqueIdsToUpsertValues($values)), + $uniqueBy, + $this->addUpdatedAtToUpsertColumns($update) + ); + } + + /** + * Update the column's update timestamp. + * + * @param null|string $column + * @return false|int + */ + public function touch($column = null) + { + $time = $this->model->freshTimestamp(); + + if ($column) { + return $this->toBase()->update([$column => $time]); + } + + $column = $this->model->getUpdatedAtColumn(); + + if (! $this->model->usesTimestamps() || is_null($column)) { + return false; + } + + return $this->toBase()->update([$column => $time]); + } + + /** + * Increment a column's value by a given amount. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @param float|int $amount + * @return int + */ + public function increment($column, $amount = 1, array $extra = []) + { + return $this->toBase()->increment( + $column, + $amount, + $this->addUpdatedAtColumn($extra) + ); + } + + /** + * Decrement a column's value by a given amount. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @param float|int $amount + * @return int + */ + public function decrement($column, $amount = 1, array $extra = []) + { + return $this->toBase()->decrement( + $column, + $amount, + $this->addUpdatedAtColumn($extra) + ); + } + + /** + * Add the "updated at" column to an array of values. + * + * @return array + */ + protected function addUpdatedAtColumn(array $values) + { + if (! $this->model->usesTimestamps() + || is_null($this->model->getUpdatedAtColumn())) { + return $values; + } + + $column = $this->model->getUpdatedAtColumn(); + + if (! array_key_exists($column, $values)) { + $timestamp = $this->model->freshTimestampString(); + + if ( + $this->model->hasSetMutator($column) + || $this->model->hasAttributeSetMutator($column) + || $this->model->hasCast($column) + ) { + $timestamp = $this->model->newInstance() + ->forceFill([$column => $timestamp]) + ->getAttributes()[$column] ?? $timestamp; + } + + $values = array_merge([$column => $timestamp], $values); + } + + $segments = preg_split('/\s+as\s+/i', $this->query->from); + + $qualifiedColumn = Arr::last($segments) . '.' . $column; + + $values[$qualifiedColumn] = Arr::get($values, $qualifiedColumn, $values[$column]); + + unset($values[$column]); + + return $values; + } + + /** + * Add unique IDs to the inserted values. + * + * @return array + */ + protected function addUniqueIdsToUpsertValues(array $values) + { + if (! $this->model->usesUniqueIds()) { + return $values; + } + + foreach ($this->model->uniqueIds() as $uniqueIdAttribute) { + foreach ($values as &$row) { + if (! array_key_exists($uniqueIdAttribute, $row)) { + $row = array_merge([$uniqueIdAttribute => $this->model->newUniqueId()], $row); + } + } + } + + return $values; + } + + /** + * Add timestamps to the inserted values. + * + * @return array + */ + protected function addTimestampsToUpsertValues(array $values) + { + if (! $this->model->usesTimestamps()) { + return $values; + } + + $timestamp = $this->model->freshTimestampString(); + + $columns = array_filter([ + $this->model->getCreatedAtColumn(), + $this->model->getUpdatedAtColumn(), + ]); + + foreach ($columns as $column) { + foreach ($values as &$row) { + $row = array_merge([$column => $timestamp], $row); + } + } + + return $values; + } + + /** + * Add the "updated at" column to the updated columns. + * + * @return array + */ + protected function addUpdatedAtToUpsertColumns(array $update) + { + if (! $this->model->usesTimestamps()) { + return $update; + } + + $column = $this->model->getUpdatedAtColumn(); + + if (! is_null($column) + && ! array_key_exists($column, $update) + && ! in_array($column, $update)) { + $update[] = $column; + } + + return $update; + } + + /** + * Delete records from the database. + * + * @return mixed + */ + public function delete() + { + if (isset($this->onDelete)) { + return call_user_func($this->onDelete, $this); + } + + return $this->toBase()->delete(); + } + + /** + * Run the default delete function on the builder. + * + * Since we do not apply scopes here, the row will actually be deleted. + * + * @return mixed + */ + public function forceDelete() + { + return $this->query->delete(); + } + + /** + * Register a replacement for the default delete function. + */ + public function onDelete(Closure $callback) + { + $this->onDelete = $callback; + } + + /** + * Determine if the given model has a scope. + * + * @param string $scope + * @return bool + */ + public function hasNamedScope($scope) + { + return $this->model && $this->model->hasNamedScope($scope); // @phpstan-ignore booleanAnd.leftAlwaysTrue (model can be null before setModel() is called) + } + + /** + * Call the given local model scopes. + * + * @param array|string $scopes + * @return mixed|static + */ + public function scopes($scopes) + { + $builder = $this; + + foreach (Arr::wrap($scopes) as $scope => $parameters) { + // If the scope key is an integer, then the scope was passed as the value and + // the parameter list is empty, so we will format the scope name and these + // parameters here. Then, we'll be ready to call the scope on the model. + if (is_int($scope)) { + [$scope, $parameters] = [$parameters, []]; + } + + // Next we'll pass the scope callback to the callScope method which will take + // care of grouping the "wheres" properly so the logical order doesn't get + // messed up when adding scopes. Then we'll return back out the builder. + $builder = $builder->callNamedScope( + $scope, + Arr::wrap($parameters) + ); + } + + return $builder; + } + + /** + * Apply the scopes to the Eloquent builder instance and return it. + * + * @return static + */ + public function applyScopes() + { + if (! $this->scopes) { + return $this; + } + + $builder = clone $this; + + foreach ($this->scopes as $identifier => $scope) { + if (! isset($builder->scopes[$identifier])) { + continue; + } + + $builder->callScope(function (self $builder) use ($scope) { + // If the scope is a Closure we will just go ahead and call the scope with the + // builder instance. The "callScope" method will properly group the clauses + // that are added to this query so "where" clauses maintain proper logic. + if ($scope instanceof Closure) { + $scope($builder); + } + + // If the scope is a scope object, we will call the apply method on this scope + // passing in the builder and the model instance. After we run all of these + // scopes we will return back the builder instance to the outside caller. + if ($scope instanceof Scope) { + $scope->apply($builder, $this->getModel()); + } + }); + } + + return $builder; + } + + /** + * Apply the given scope on the current builder instance. + * + * @return mixed + */ + protected function callScope(callable $scope, array $parameters = []) + { + array_unshift($parameters, $this); + + $query = $this->getQuery(); + + // We will keep track of how many wheres are on the query before running the + // scope so that we can properly group the added scope constraints in the + // query as their own isolated nested where statement and avoid issues. + $originalWhereCount = count($query->wheres); + + $result = $scope(...$parameters) ?? $this; + + if (count((array) $query->wheres) > $originalWhereCount) { + $this->addNewWheresWithinGroup($query, $originalWhereCount); + } + + return $result; + } + + /** + * Apply the given named scope on the current builder instance. + * + * @param string $scope + * @return mixed + */ + protected function callNamedScope($scope, array $parameters = []) + { + return $this->callScope(function (...$parameters) use ($scope) { + return $this->model->callNamedScope($scope, $parameters); + }, $parameters); + } + + /** + * Nest where conditions by slicing them at the given where count. + * + * @param int $originalWhereCount + */ + protected function addNewWheresWithinGroup(QueryBuilder $query, $originalWhereCount) + { + // Here, we totally remove all of the where clauses since we are going to + // rebuild them as nested queries by slicing the groups of wheres into + // their own sections. This is to prevent any confusing logic order. + $allWheres = $query->wheres; + + $query->wheres = []; + + $this->groupWhereSliceForScope( + $query, + array_slice($allWheres, 0, $originalWhereCount) + ); + + $this->groupWhereSliceForScope( + $query, + array_slice($allWheres, $originalWhereCount) + ); + } + + /** + * Slice where conditions at the given offset and add them to the query as a nested condition. + * + * @param array $whereSlice + */ + protected function groupWhereSliceForScope(QueryBuilder $query, $whereSlice) + { + $whereBooleans = (new BaseCollection($whereSlice))->pluck('boolean'); + + // Here we'll check if the given subset of where clauses contains any "or" + // booleans and in this case create a nested where expression. That way + // we don't add any unnecessary nesting thus keeping the query clean. + // @phpstan-ignore argument.type (where clause 'boolean' is always string, pluck loses type info) + if ($whereBooleans->contains(fn ($logicalOperator) => str_contains($logicalOperator, 'or'))) { + $query->wheres[] = $this->createNestedWhere( + // @phpstan-ignore argument.type (where clause 'boolean' is always string) + $whereSlice, + str_replace(' not', '', $whereBooleans->first()) + ); + } else { + $query->wheres = array_merge($query->wheres, $whereSlice); + } + } + + /** + * Create a where array with nested where conditions. + * + * @param array $whereSlice + * @param string $boolean + * @return array + */ + protected function createNestedWhere($whereSlice, $boolean = 'and') + { + $whereGroup = $this->getQuery()->forNestedWhere(); + + $whereGroup->wheres = $whereSlice; + + return ['type' => 'Nested', 'query' => $whereGroup, 'boolean' => $boolean]; + } + + /** + * Specify relationships that should be eager loaded. + * + * @param array): mixed)|string>|string $relations + * @param (\Closure(\Hypervel\Database\Eloquent\Relations\Relation<*,*,*>): mixed)|string|null $callback + * @return $this + */ + public function with($relations, $callback = null) + { + if ($callback instanceof Closure) { + $eagerLoad = $this->parseWithRelations([$relations => $callback]); + } else { + $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations); + } + + $this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad); + + return $this; + } + + /** + * Prevent the specified relations from being eager loaded. + * + * @param mixed $relations + * @return $this + */ + public function without($relations) + { + $this->eagerLoad = array_diff_key($this->eagerLoad, array_flip( + is_string($relations) ? func_get_args() : $relations + )); + + return $this; + } + + /** + * 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($relations) + { + $this->eagerLoad = []; + + return $this->with($relations); + } + + /** + * Create a new instance of the model being queried. + * + * @param array $attributes + * @return TModel + */ + public function newModelInstance($attributes = []) + { + $attributes = array_merge($this->pendingAttributes, $attributes); + + return $this->model->newInstance($attributes)->setConnection( + $this->query->getConnection()->getName() + ); + } + + /** + * Parse a list of relations into individuals. + * + * @return array + */ + protected function parseWithRelations(array $relations) + { + if ($relations === []) { + return []; + } + + $results = []; + + foreach ($this->prepareNestedWithRelationships($relations) as $name => $constraints) { + // We need to separate out any nested includes, which allows the developers + // to load deep relationships using "dots" without stating each level of + // the relationship with its own key in the array of eager-load names. + $results = $this->addNestedWiths($name, $results); + + $results[$name] = $constraints; + } + + return $results; + } + + /** + * Prepare nested with relationships. + * + * @param array $relations + * @param string $prefix + * @return array + */ + protected function prepareNestedWithRelationships($relations, $prefix = '') + { + $preparedRelationships = []; + + if ($prefix !== '') { + $prefix .= '.'; + } + + // If any of the relationships are formatted with the [$attribute => array()] + // syntax, we shall loop over the nested relations and prepend each key of + // this array while flattening into the traditional dot notation format. + foreach ($relations as $key => $value) { + if (! is_string($key) || ! is_array($value)) { + continue; + } + + [$attribute, $attributeSelectConstraint] = $this->parseNameAndAttributeSelectionConstraint($key); + + $preparedRelationships = array_merge( + $preparedRelationships, + ["{$prefix}{$attribute}" => $attributeSelectConstraint], + $this->prepareNestedWithRelationships($value, "{$prefix}{$attribute}"), + ); + + unset($relations[$key]); + } + + // We now know that the remaining relationships are in a dot notation format + // and may be a string or Closure. We'll loop over them and ensure all of + // the present Closures are merged + strings are made into constraints. + foreach ($relations as $key => $value) { + if (is_numeric($key) && is_string($value)) { + [$key, $value] = $this->parseNameAndAttributeSelectionConstraint($value); + } + + $preparedRelationships[$prefix . $key] = $this->combineConstraints([ + $value, + $preparedRelationships[$prefix . $key] ?? static function () { + }, + ]); + } + + return $preparedRelationships; + } + + /** + * Combine an array of constraints into a single constraint. + * + * @return Closure + */ + protected function combineConstraints(array $constraints) + { + return function ($builder) use ($constraints) { + foreach ($constraints as $constraint) { + $builder = $constraint($builder) ?? $builder; + } + + return $builder; + }; + } + + /** + * Parse the attribute select constraints from the name. + * + * @param string $name + * @return array + */ + protected function parseNameAndAttributeSelectionConstraint($name) + { + return str_contains($name, ':') + ? $this->createSelectWithConstraint($name) + : [$name, static function () { + }]; + } + + /** + * Create a constraint to select the given columns for the relation. + * + * @param string $name + * @return array + */ + protected function createSelectWithConstraint($name) + { + return [explode(':', $name)[0], static function ($query) use ($name) { + $query->select(array_map(static function ($column) use ($query) { + return $query instanceof BelongsToMany + ? $query->getRelated()->qualifyColumn($column) + : $column; + }, explode(',', explode(':', $name)[1]))); + }]; + } + + /** + * Parse the nested relationships in a relation. + * + * @param string $name + * @param array $results + * @return array + */ + protected function addNestedWiths($name, $results) + { + $progress = []; + + // If the relation has already been set on the result array, we will not set it + // again, since that would override any constraints that were already placed + // on the relationships. We will only set the ones that are not specified. + foreach (explode('.', $name) as $segment) { + $progress[] = $segment; + + if (! isset($results[$last = implode('.', $progress)])) { + $results[$last] = static function () { + }; + } + } + + return $results; + } + + /** + * 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 mixed $value + * @param bool $asConditions + * @return $this + */ + public function withAttributes(Expression|array|string $attributes, $value = null, $asConditions = true) + { + if (! is_array($attributes)) { + $attributes = [$attributes => $value]; + } + + if ($asConditions) { + foreach ($attributes as $column => $value) { + $this->where($this->qualifyColumn($column), $value); + } + } + + $this->pendingAttributes = array_merge($this->pendingAttributes, $attributes); + + return $this; + } + + /** + * Apply query-time casts to the model instance. + * + * @param array $casts + * @return $this + */ + public function withCasts($casts) + { + $this->model->mergeCasts($casts); + + return $this; + } + + /** + * Execute the given Closure within a transaction savepoint if needed. + * + * @template TModelValue + * + * @param Closure(): TModelValue $scope + * @return TModelValue + */ + public function withSavepointIfNeeded(Closure $scope): mixed + { + return $this->getQuery()->getConnection()->transactionLevel() > 0 + ? $this->getQuery()->getConnection()->transaction($scope) + : $scope(); + } + + /** + * Get the Eloquent builder instances that are used in the union of the query. + * + * @return \Hypervel\Support\Collection + */ + protected function getUnionBuilders() + { + return isset($this->query->unions) + ? (new BaseCollection($this->query->unions))->pluck('query') + : new BaseCollection(); + } + + /** + * Get the underlying query builder instance. + * + * @return \Hypervel\Database\Query\Builder + */ + public function getQuery() + { + return $this->query; + } + + /** + * Set the underlying query builder instance. + * + * @param \Hypervel\Database\Query\Builder $query + * @return $this + */ + public function setQuery($query) + { + $this->query = $query; + + return $this; + } + + /** + * Get a base query builder instance. + * + * @return \Hypervel\Database\Query\Builder + */ + public function toBase() + { + return $this->applyScopes()->getQuery(); + } + + /** + * Get the relationships being eagerly loaded. + * + * @return array + */ + public function getEagerLoads() + { + return $this->eagerLoad; + } + + /** + * Set the relationships being eagerly loaded. + * + * @return $this + */ + public function setEagerLoads(array $eagerLoad) + { + $this->eagerLoad = $eagerLoad; + + return $this; + } + + /** + * Indicate that the given relationships should not be eagerly loaded. + * + * @return $this + */ + public function withoutEagerLoad(array $relations) + { + $relations = array_diff(array_keys($this->model->getRelations()), $relations); + + return $this->with($relations); + } + + /** + * Flush the relationships being eagerly loaded. + * + * @return $this + */ + public function withoutEagerLoads() + { + return $this->setEagerLoads([]); + } + + /** + * Get the "limit" value from the query or null if it's not set. + * + * @return mixed + */ + public function getLimit() + { + return $this->query->getLimit(); + } + + /** + * Get the "offset" value from the query or null if it's not set. + * + * @return mixed + */ + public function getOffset() + { + return $this->query->getOffset(); + } + + /** + * Get the default key name of the table. + * + * @return string + */ + protected function defaultKeyName() + { + return $this->getModel()->getKeyName(); + } + + /** + * Get the model instance being queried. + * + * @return TModel + */ + public function getModel() + { + return $this->model; + } + + /** + * Set a model instance for the model being queried. + * + * @template TModelNew of \Hypervel\Database\Eloquent\Model + * + * @param TModelNew $model + * @return static + */ + public function setModel(Model $model) + { + $this->model = $model; + + $this->query->from($model->getTable()); + + // @phpstan-ignore return.type (PHPDoc expresses type change that PHP can't verify at compile time) + return $this; + } + + /** + * Qualify the given column name by the model's table. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return string + */ + public function qualifyColumn($column) + { + $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + + return $this->model->qualifyColumn($column); + } + + /** + * Qualify the given columns with the model's table. + * + * @param array|\Hypervel\Contracts\Database\Query\Expression $columns + * @return array + */ + public function qualifyColumns($columns) + { + return $this->model->qualifyColumns($columns); + } + + /** + * Get the given macro by name. + * + * @param string $name + * @return Closure + */ + public function getMacro($name) + { + return Arr::get($this->localMacros, $name); + } + + /** + * Checks if a macro is registered. + * + * @param string $name + * @return bool + */ + public function hasMacro($name) + { + return isset($this->localMacros[$name]); + } + + /** + * Get the given global macro by name. + * + * @param string $name + * @return Closure + */ + public static function getGlobalMacro($name) + { + return Arr::get(static::$macros, $name); + } + + /** + * Checks if a global macro is registered. + * + * @param string $name + * @return bool + */ + public static function hasGlobalMacro($name) + { + return isset(static::$macros[$name]); + } + + /** + * Dynamically access builder proxies. + * + * @param string $key + * @return mixed + * + * @throws Exception + */ + public function __get($key) + { + if (in_array($key, ['orWhere', 'whereNot', 'orWhereNot'])) { + return new HigherOrderBuilderProxy($this, $key); + } + + if (in_array($key, $this->propertyPassthru)) { + return $this->toBase()->{$key}; + } + + throw new Exception("Property [{$key}] does not exist on the Eloquent builder instance."); + } + + /** + * Dynamically handle calls into the query instance. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + if ($method === 'macro') { + $this->localMacros[$parameters[0]] = $parameters[1]; + + return; + } + + if ($this->hasMacro($method)) { + array_unshift($parameters, $this); + + return $this->localMacros[$method](...$parameters); + } + + if (static::hasGlobalMacro($method)) { + $callable = static::$macros[$method]; + + if ($callable instanceof Closure) { + $callable = $callable->bindTo($this, static::class); + } + + return $callable(...$parameters); + } + + if ($this->hasNamedScope($method)) { + return $this->callNamedScope($method, $parameters); + } + + if (in_array(strtolower($method), $this->passthru)) { + return $this->toBase()->{$method}(...$parameters); + } + + $this->forwardCallTo($this->query, $method, $parameters); + + return $this; + } + + /** + * Dynamically handle calls into the query instance. + * + * @param string $method + * @param array $parameters + * @return mixed + * + * @throws BadMethodCallException + */ + public static function __callStatic($method, $parameters) + { + if ($method === 'macro') { + static::$macros[$parameters[0]] = $parameters[1]; + + return; + } + + if ($method === 'mixin') { + static::registerMixin($parameters[0], $parameters[1] ?? true); + + return; + } + + if (! static::hasGlobalMacro($method)) { + static::throwBadMethodCallException($method); + } + + $callable = static::$macros[$method]; + + if ($callable instanceof Closure) { + $callable = $callable->bindTo(null, static::class); + } + + return $callable(...$parameters); + } + + /** + * Register the given mixin with the builder. + */ + protected static function registerMixin(object $mixin, bool $replace): void + { + $methods = (new ReflectionClass($mixin))->getMethods( + ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED + ); + + foreach ($methods as $method) { + if ($replace || ! static::hasGlobalMacro($method->name)) { + static::macro($method->name, $method->invoke($mixin)); + } + } + } + + /** + * Clone the Eloquent query builder. + * + * @return static + */ + public function clone() + { + return clone $this; + } + + /** + * Register a closure to be invoked on a clone. + * + * @return $this + */ + public function onClone(Closure $callback) + { + $this->onCloneCallbacks[] = $callback; + + return $this; + } + + /** + * Force a clone of the underlying query builder when cloning. + */ + public function __clone() + { + $this->query = clone $this->query; + + foreach ($this->onCloneCallbacks as $onCloneCallback) { + $onCloneCallback($this); + } + } +} diff --git a/src/database/src/Eloquent/Casts/ArrayObject.php b/src/database/src/Eloquent/Casts/ArrayObject.php new file mode 100644 index 000000000..3bd12b71c --- /dev/null +++ b/src/database/src/Eloquent/Casts/ArrayObject.php @@ -0,0 +1,43 @@ + + */ +class ArrayObject extends BaseArrayObject implements Arrayable, JsonSerializable +{ + /** + * Get a collection containing the underlying array. + */ + public function collect(): Collection + { + return new Collection($this->getArrayCopy()); + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + return $this->getArrayCopy(); + } + + /** + * Get the array that should be JSON serialized. + */ + public function jsonSerialize(): array + { + return $this->getArrayCopy(); + } +} diff --git a/src/database/src/Eloquent/Casts/AsArrayObject.php b/src/database/src/Eloquent/Casts/AsArrayObject.php new file mode 100644 index 000000000..43b4575be --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsArrayObject.php @@ -0,0 +1,42 @@ +, iterable> + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes { + public function get(mixed $model, string $key, mixed $value, array $attributes): ?ArrayObject + { + if (! isset($attributes[$key])) { + return null; + } + + $data = Json::decode($attributes[$key]); + + return is_array($data) ? new ArrayObject($data, ArrayObject::ARRAY_AS_PROPS) : null; + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): array + { + return [$key => Json::encode($value)]; + } + + public function serialize(mixed $model, string $key, mixed $value, array $attributes): array + { + return $value->getArrayCopy(); + } + }; + } +} diff --git a/src/database/src/Eloquent/Casts/AsBinary.php b/src/database/src/Eloquent/Casts/AsBinary.php new file mode 100644 index 000000000..d7a063530 --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsBinary.php @@ -0,0 +1,73 @@ +format = $this->arguments[0] + ?? throw new InvalidArgumentException('The binary codec format is required.'); + + if (! in_array($this->format, BinaryCodec::formats(), true)) { + throw new InvalidArgumentException(sprintf( + 'Unsupported binary codec format [%s]. Allowed formats are: %s.', + $this->format, + implode(', ', BinaryCodec::formats()), + )); + } + } + + public function get(mixed $model, string $key, mixed $value, array $attributes): ?string + { + return BinaryCodec::decode($attributes[$key] ?? null, $this->format); + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): array + { + return [$key => BinaryCodec::encode($value, $this->format)]; + } + }; + } + + /** + * Encode / decode values as binary UUIDs. + */ + public static function uuid(): string + { + return self::class . ':uuid'; + } + + /** + * Encode / decode values as binary ULIDs. + */ + public static function ulid(): string + { + return self::class . ':ulid'; + } + + /** + * Encode / decode values using the given format. + */ + public static function of(string $format): string + { + return self::class . ':' . $format; + } +} diff --git a/src/database/src/Eloquent/Casts/AsCollection.php b/src/database/src/Eloquent/Casts/AsCollection.php new file mode 100644 index 000000000..8a635a83b --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsCollection.php @@ -0,0 +1,94 @@ +, iterable> + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class($arguments) implements CastsAttributes { + public function __construct(protected array $arguments) + { + $this->arguments = array_pad(array_values($this->arguments), 2, ''); + } + + public function get(mixed $model, string $key, mixed $value, array $attributes): ?Collection + { + if (! isset($attributes[$key])) { + return null; + } + + $data = Json::decode($attributes[$key]); + + $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; + + if (! is_a($collectionClass, Collection::class, true)) { + throw new InvalidArgumentException('The provided class must extend [' . Collection::class . '].'); + } + + if (! is_array($data)) { + return null; + } + + $instance = new $collectionClass($data); + + if (! isset($this->arguments[1]) || ! $this->arguments[1]) { + return $instance; + } + + if (is_string($this->arguments[1])) { + $this->arguments[1] = Str::parseCallback($this->arguments[1]); + } + + return is_callable($this->arguments[1]) + ? $instance->map($this->arguments[1]) + : $instance->mapInto($this->arguments[1][0]); + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): array + { + return [$key => Json::encode($value)]; + } + }; + } + + /** + * Specify the type of object each item in the collection should be mapped to. + * + * @param array{class-string, string}|class-string $map + */ + public static function of(array|string $map): string + { + // @phpstan-ignore argument.type (using() expects class-string, but '' is valid for default collection) + return static::using('', $map); + } + + /** + * Specify the collection type for the cast. + * + * @param class-string $class + * @param null|array{class-string, string}|class-string $map + */ + public static function using(string $class, array|string|null $map = null): string + { + if (is_array($map) && is_callable($map)) { + $map = $map[0] . '@' . $map[1]; + } + + // @phpstan-ignore argument.type (implode handles null gracefully for serialization format) + return static::class . ':' . implode(',', [$class, $map]); + } +} diff --git a/src/core/src/Database/Eloquent/Casts/AsDataObject.php b/src/database/src/Eloquent/Casts/AsDataObject.php similarity index 91% rename from src/core/src/Database/Eloquent/Casts/AsDataObject.php rename to src/database/src/Eloquent/Casts/AsDataObject.php index b52b86019..fbcbafe45 100644 --- a/src/core/src/Database/Eloquent/Casts/AsDataObject.php +++ b/src/database/src/Eloquent/Casts/AsDataObject.php @@ -4,7 +4,8 @@ namespace Hypervel\Database\Eloquent\Casts; -use Hyperf\Contract\CastsAttributes; +use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\DataObject; use InvalidArgumentException; @@ -26,10 +27,9 @@ public function __construct( * Cast the given value. * * @param array $attributes - * @param mixed $model */ public function get( - $model, + Model $model, string $key, mixed $value, array $attributes, @@ -48,10 +48,9 @@ public function get( * Prepare the given value for storage. * * @param array $attributes - * @param mixed $model */ public function set( - $model, + Model $model, string $key, mixed $value, array $attributes, diff --git a/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php new file mode 100644 index 000000000..d3b2b0a77 --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php @@ -0,0 +1,45 @@ +, iterable> + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes { + public function get(mixed $model, string $key, mixed $value, array $attributes): ?ArrayObject + { + if (isset($attributes[$key])) { + return new ArrayObject(Json::decode(Crypt::decryptString($attributes[$key]))); + } + + return null; + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): ?array + { + if (! is_null($value)) { + return [$key => Crypt::encryptString(Json::encode($value))]; + } + + return null; + } + + public function serialize(mixed $model, string $key, mixed $value, array $attributes): ?array + { + return ! is_null($value) ? $value->getArrayCopy() : null; + } + }; + } +} diff --git a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php new file mode 100644 index 000000000..86a4a3d12 --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php @@ -0,0 +1,93 @@ +, iterable> + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class($arguments) implements CastsAttributes { + public function __construct(protected array $arguments) + { + $this->arguments = array_pad(array_values($this->arguments), 2, ''); + } + + public function get(mixed $model, string $key, mixed $value, array $attributes): ?Collection + { + $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; + + if (! is_a($collectionClass, Collection::class, true)) { + throw new InvalidArgumentException('The provided class must extend [' . Collection::class . '].'); + } + + if (! isset($attributes[$key])) { + return null; + } + + $instance = new $collectionClass(Json::decode(Crypt::decryptString($attributes[$key]))); + + if (! isset($this->arguments[1]) || ! $this->arguments[1]) { + return $instance; + } + + if (is_string($this->arguments[1])) { + $this->arguments[1] = Str::parseCallback($this->arguments[1]); + } + + return is_callable($this->arguments[1]) + ? $instance->map($this->arguments[1]) + : $instance->mapInto($this->arguments[1][0]); + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): ?array + { + if (! is_null($value)) { + return [$key => Crypt::encryptString(Json::encode($value))]; + } + + return null; + } + }; + } + + /** + * Specify the type of object each item in the collection should be mapped to. + * + * @param array{class-string, string}|class-string $map + */ + public static function of(array|string $map): string + { + // @phpstan-ignore argument.type (using() expects class-string, but '' is valid for default collection) + return static::using('', $map); + } + + /** + * Specify the collection for the cast. + * + * @param class-string $class + * @param null|array{class-string, string}|class-string $map + */ + public static function using(string $class, array|string|null $map = null): string + { + if (is_array($map) && is_callable($map)) { + $map = $map[0] . '@' . $map[1]; + } + + // @phpstan-ignore argument.type (implode handles null gracefully for serialization format) + return static::class . ':' . implode(',', [$class, $map]); + } +} diff --git a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php new file mode 100644 index 000000000..454fdba1e --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php @@ -0,0 +1,97 @@ +} $arguments + * @return CastsAttributes, iterable> + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class($arguments) implements CastsAttributes { + protected array $arguments; + + public function __construct(array $arguments) + { + $this->arguments = $arguments; + } + + public function get(mixed $model, string $key, mixed $value, array $attributes): ?ArrayObject + { + if (! isset($attributes[$key])) { + return null; + } + + $data = Json::decode($attributes[$key]); + + if (! is_array($data)) { + return null; + } + + $enumClass = $this->arguments[0]; + + return new ArrayObject((new Collection($data))->map(function ($value) use ($enumClass) { + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($value) + : constant($enumClass . '::' . $value); + })->toArray()); + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): array + { + if ($value === null) { + return [$key => null]; + } + + $storable = []; + + foreach ($value as $enum) { + $storable[] = $this->getStorableEnumValue($enum); + } + + return [$key => Json::encode($storable)]; + } + + public function serialize(mixed $model, string $key, mixed $value, array $attributes): array + { + return (new Collection($value->getArrayCopy())) + ->map(fn ($enum) => $this->getStorableEnumValue($enum)) + ->toArray(); + } + + protected function getStorableEnumValue(mixed $enum): string|int + { + if (is_string($enum) || is_int($enum)) { + return $enum; + } + + return enum_value($enum); + } + }; + } + + /** + * Specify the Enum for the cast. + * + * @param class-string $class + */ + public static function of(string $class): string + { + return static::class . ':' . $class; + } +} diff --git a/src/database/src/Eloquent/Casts/AsEnumCollection.php b/src/database/src/Eloquent/Casts/AsEnumCollection.php new file mode 100644 index 000000000..23b2847c0 --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsEnumCollection.php @@ -0,0 +1,93 @@ +} $arguments + * @return CastsAttributes, iterable> + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class($arguments) implements CastsAttributes { + protected array $arguments; + + public function __construct(array $arguments) + { + $this->arguments = $arguments; + } + + public function get(mixed $model, string $key, mixed $value, array $attributes): ?Collection + { + if (! isset($attributes[$key])) { + return null; + } + + $data = Json::decode($attributes[$key]); + + if (! is_array($data)) { + return null; + } + + $enumClass = $this->arguments[0]; + + return (new Collection($data))->map(function ($value) use ($enumClass) { + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($value) + : constant($enumClass . '::' . $value); + }); + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): array + { + $value = $value !== null + ? Json::encode((new Collection($value))->map(function ($enum) { + return $this->getStorableEnumValue($enum); + })->jsonSerialize()) + : null; + + return [$key => $value]; + } + + public function serialize(mixed $model, string $key, mixed $value, array $attributes): array + { + return (new Collection($value)) + ->map(fn ($enum) => $this->getStorableEnumValue($enum)) + ->toArray(); + } + + protected function getStorableEnumValue(mixed $enum): string|int + { + if (is_string($enum) || is_int($enum)) { + return $enum; + } + + return enum_value($enum); + } + }; + } + + /** + * Specify the Enum for the cast. + * + * @param class-string $class + */ + public static function of(string $class): string + { + return static::class . ':' . $class; + } +} diff --git a/src/database/src/Eloquent/Casts/AsFluent.php b/src/database/src/Eloquent/Casts/AsFluent.php new file mode 100644 index 000000000..52a00f69e --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsFluent.php @@ -0,0 +1,32 @@ + + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes { + public function get(mixed $model, string $key, mixed $value, array $attributes): ?Fluent + { + return isset($value) ? new Fluent(Json::decode($value)) : null; + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): ?array + { + return isset($value) ? [$key => Json::encode($value)] : null; + } + }; + } +} diff --git a/src/database/src/Eloquent/Casts/AsHtmlString.php b/src/database/src/Eloquent/Casts/AsHtmlString.php new file mode 100644 index 000000000..458c6314e --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsHtmlString.php @@ -0,0 +1,32 @@ + + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes { + public function get(mixed $model, string $key, mixed $value, array $attributes): ?HtmlString + { + return isset($value) ? new HtmlString($value) : null; + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): ?string + { + return isset($value) ? (string) $value : null; + } + }; + } +} diff --git a/src/database/src/Eloquent/Casts/AsStringable.php b/src/database/src/Eloquent/Casts/AsStringable.php new file mode 100644 index 000000000..f11bba196 --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsStringable.php @@ -0,0 +1,32 @@ + + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes { + public function get(mixed $model, string $key, mixed $value, array $attributes): ?Stringable + { + return isset($value) ? new Stringable($value) : null; + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): ?string + { + return isset($value) ? (string) $value : null; + } + }; + } +} diff --git a/src/database/src/Eloquent/Casts/AsUri.php b/src/database/src/Eloquent/Casts/AsUri.php new file mode 100644 index 000000000..561919288 --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsUri.php @@ -0,0 +1,32 @@ + + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes { + public function get(mixed $model, string $key, mixed $value, array $attributes): ?Uri + { + return isset($value) ? new Uri($value) : null; + } + + public function set(mixed $model, string $key, mixed $value, array $attributes): ?string + { + return isset($value) ? (string) $value : null; + } + }; + } +} diff --git a/src/database/src/Eloquent/Casts/Attribute.php b/src/database/src/Eloquent/Casts/Attribute.php new file mode 100644 index 000000000..e6a4d364c --- /dev/null +++ b/src/database/src/Eloquent/Casts/Attribute.php @@ -0,0 +1,85 @@ +get = $get; + $this->set = $set; + } + + /** + * Create a new attribute accessor / mutator. + */ + public static function make(?callable $get = null, ?callable $set = null): static + { + return new static($get, $set); + } + + /** + * Create a new attribute accessor. + */ + public static function get(callable $get): static + { + return new static($get); + } + + /** + * Create a new attribute mutator. + */ + public static function set(callable $set): static + { + return new static(null, $set); + } + + /** + * Disable object caching for the attribute. + */ + public function withoutObjectCaching(): static + { + $this->withObjectCaching = false; + + return $this; + } + + /** + * Enable caching for the attribute. + */ + public function shouldCache(): static + { + $this->withCaching = true; + + return $this; + } +} diff --git a/src/database/src/Eloquent/Casts/Json.php b/src/database/src/Eloquent/Casts/Json.php new file mode 100644 index 000000000..da54269ae --- /dev/null +++ b/src/database/src/Eloquent/Casts/Json.php @@ -0,0 +1,58 @@ + + */ +class Collection extends BaseCollection implements QueueableCollection +{ + use InteractsWithDictionary; + + /** + * Find a model in the collection by key. + * + * @template TFindDefault + * + * @param mixed $key + * @param TFindDefault $default + * @return ($key is (array|\Hypervel\Contracts\Support\Arrayable) ? static : TFindDefault|TModel) + */ + public function find($key, $default = null) + { + if ($key instanceof Model) { + $key = $key->getKey(); + } + + if ($key instanceof Arrayable) { + $key = $key->toArray(); + } + + if (is_array($key)) { + if ($this->isEmpty()) { + return new static(); + } + + return $this->whereIn($this->first()->getKeyName(), $key); + } + + return Arr::first($this->items, fn ($model) => $model->getKey() == $key, $default); + } + + /** + * Find a model in the collection by key or throw an exception. + * + * @param mixed $key + * @return TModel + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + */ + public function findOrFail($key) + { + $result = $this->find($key); + + if (is_array($key) && count($result) === count(array_unique($key))) { + return $result; + } + if (! is_array($key) && ! is_null($result)) { + return $result; + } + + $exception = new ModelNotFoundException(); + + if (! $model = head($this->items)) { + throw $exception; + } + + $ids = is_array($key) ? array_diff($key, $result->modelKeys()) : $key; + + $exception->setModel(get_class($model), $ids); + + throw $exception; + } + + /** + * Load a set of relationships onto the collection. + * + * @param array): mixed)|string>|string $relations + * @return $this + */ + public function load($relations) + { + if ($this->isNotEmpty()) { + if (is_string($relations)) { + $relations = func_get_args(); + } + + $query = $this->first()->newQueryWithoutRelationships()->with($relations); + + $this->items = $query->eagerLoadRelations($this->items); + } + + return $this; + } + + /** + * Load a set of aggregations over relationship's column onto the collection. + * + * @param array): mixed)|string>|string $relations + * @param string $column + * @param null|string $function + * @return $this + */ + public function loadAggregate($relations, $column, $function = null) + { + if ($this->isEmpty()) { + return $this; + } + + // @phpstan-ignore method.notFound (withAggregate is on Eloquent\Builder; PHPStan loses type through chain) + $models = $this->first()->newModelQuery() + ->whereKey($this->modelKeys()) + ->select($this->first()->getKeyName()) + ->withAggregate($relations, $column, $function) + ->get() + ->keyBy($this->first()->getKeyName()); + + $attributes = Arr::except( + array_keys($models->first()->getAttributes()), + $models->first()->getKeyName() + ); + + $this->each(function ($model) use ($models, $attributes) { + $extraAttributes = Arr::only($models->get($model->getKey())->getAttributes(), $attributes); + + $model->forceFill($extraAttributes) + ->syncOriginalAttributes($attributes) + ->mergeCasts($models->get($model->getKey())->getCasts()); + }); + + return $this; + } + + /** + * Load a set of relationship counts onto the collection. + * + * @param array): mixed)|string>|string $relations + * @return $this + */ + public function loadCount($relations) + { + return $this->loadAggregate($relations, '*', 'count'); + } + + /** + * Load a set of relationship's max column values onto the collection. + * + * @param array): mixed)|string>|string $relations + * @param string $column + * @return $this + */ + public function loadMax($relations, $column) + { + return $this->loadAggregate($relations, $column, 'max'); + } + + /** + * Load a set of relationship's min column values onto the collection. + * + * @param array): mixed)|string>|string $relations + * @param string $column + * @return $this + */ + public function loadMin($relations, $column) + { + return $this->loadAggregate($relations, $column, 'min'); + } + + /** + * Load a set of relationship's column summations onto the collection. + * + * @param array): mixed)|string>|string $relations + * @param string $column + * @return $this + */ + public function loadSum($relations, $column) + { + return $this->loadAggregate($relations, $column, 'sum'); + } + + /** + * Load a set of relationship's average column values onto the collection. + * + * @param array): mixed)|string>|string $relations + * @param string $column + * @return $this + */ + public function loadAvg($relations, $column) + { + return $this->loadAggregate($relations, $column, 'avg'); + } + + /** + * Load a set of related existences onto the collection. + * + * @param array): mixed)|string>|string $relations + * @return $this + */ + public function loadExists($relations) + { + return $this->loadAggregate($relations, '*', 'exists'); + } + + /** + * Load a set of relationships onto the collection if they are not already eager loaded. + * + * @param array): mixed)|string>|string $relations + * @return $this + */ + public function loadMissing($relations) + { + if (is_string($relations)) { + $relations = func_get_args(); + } + + if ($this->isNotEmpty()) { + $query = $this->first()->newQueryWithoutRelationships()->with($relations); + + foreach ($query->getEagerLoads() as $key => $value) { + $segments = explode('.', explode(':', $key)[0]); + + if (str_contains($key, ':')) { + $segments[count($segments) - 1] .= ':' . explode(':', $key)[1]; + } + + $path = []; + + foreach ($segments as $segment) { + $path[] = [$segment => $segment]; + } + + if (is_callable($value)) { + $path[count($segments) - 1][Arr::last($segments)] = $value; + } + + $this->loadMissingRelation($this, $path); + } + } + + return $this; + } + + /** + * Load a relationship path for models of the given type if it is not already eager loaded. + * + * @param array $tuples + */ + public function loadMissingRelationshipChain(array $tuples): void + { + [$relation, $class] = array_shift($tuples); + + $this->filter(function ($model) use ($relation, $class) { + // @phpstan-ignore function.impossibleType (collection may contain nulls at runtime) + return ! is_null($model) + && ! $model->relationLoaded($relation) + && $model::class === $class; + })->load($relation); + + if (empty($tuples)) { + return; + } + + $models = $this->pluck($relation)->whereNotNull(); + + if ($models->first() instanceof BaseCollection) { + $models = $models->collapse(); + } + + (new static($models))->loadMissingRelationshipChain($tuples); + } + + /** + * Load a relationship path if it is not already eager loaded. + * + * @param \Hypervel\Database\Eloquent\Collection $models + */ + protected function loadMissingRelation(self $models, array $path) + { + $relation = array_shift($path); + + $name = explode(':', key($relation))[0]; + + if (is_string(reset($relation))) { + $relation = reset($relation); + } + + // @phpstan-ignore function.impossibleType (collection may contain nulls at runtime) + $models->filter(fn ($model) => ! is_null($model) && ! $model->relationLoaded($name))->load($relation); + + if (empty($path)) { + return; + } + + $models = $models->pluck($name)->filter(); + + if ($models->first() instanceof BaseCollection) { + $models = $models->collapse(); + } + + $this->loadMissingRelation(new static($models), $path); + } + + /** + * Load a set of relationships onto the mixed relationship collection. + * + * @param string $relation + * @param array): mixed)|string> $relations + * @return $this + */ + public function loadMorph($relation, $relations) + { + $this->pluck($relation) + ->filter() + ->groupBy(fn ($model) => get_class($model)) + ->each(fn ($models, $className) => static::make($models)->load($relations[$className] ?? [])); + + return $this; + } + + /** + * Load a set of relationship counts onto the mixed relationship collection. + * + * @param string $relation + * @param array): mixed)|string> $relations + * @return $this + */ + public function loadMorphCount($relation, $relations) + { + $this->pluck($relation) + ->filter() + ->groupBy(fn ($model) => get_class($model)) + ->each(fn ($models, $className) => static::make($models)->loadCount($relations[$className] ?? [])); + + return $this; + } + + /** + * Determine if a key exists in the collection. + * + * @param (callable(TModel, TKey): bool)|int|string|TModel $key + * @param mixed $operator + * @param mixed $value + */ + public function contains($key, $operator = null, $value = null): bool + { + if (func_num_args() > 1 || $this->useAsCallable($key)) { + return parent::contains(...func_get_args()); + } + + if ($key instanceof Model) { + return parent::contains(fn ($model) => $model->is($key)); + } + + return parent::contains(fn ($model) => $model->getKey() == $key); + } + + /** + * Determine if a key does not exist in the collection. + * + * @param (callable(TModel, TKey): bool)|int|string|TModel $key + * @param mixed $operator + * @param mixed $value + */ + public function doesntContain($key, $operator = null, $value = null): bool + { + return ! $this->contains(...func_get_args()); + } + + /** + * Get the array of primary keys. + * + * @return array + */ + public function modelKeys() + { + return array_map(fn ($model) => $model->getKey(), $this->items); + } + + /** + * Merge the collection with the given items. + * + * @param iterable $items + * @return static + */ + public function merge($items): static + { + $dictionary = $this->getDictionary(); + + foreach ($items as $item) { + $dictionary[$this->getDictionaryKey($item->getKey())] = $item; + } + + return new static(array_values($dictionary)); + } + + /** + * Run a map over each of the items. + * + * @template TMapValue + * + * @param callable(TModel, TKey): TMapValue $callback + * @return \Hypervel\Support\Collection|static + */ + public function map(callable $callback) + { + $result = parent::map($callback); + + // @phpstan-ignore instanceof.alwaysTrue (callback may transform to non-Model types) + return $result->contains(fn ($item) => ! $item instanceof Model) ? $result->toBase() : $result; + } + + /** + * Run an associative map over each of the items. + * + * The callback should return an associative array with a single key / value pair. + * + * @template TMapWithKeysKey of array-key + * @template TMapWithKeysValue + * + * @param callable(TModel, TKey): array $callback + * @return \Hypervel\Support\Collection|static + */ + public function mapWithKeys(callable $callback) + { + $result = parent::mapWithKeys($callback); + + // @phpstan-ignore instanceof.alwaysTrue (callback may transform to non-Model types) + return $result->contains(fn ($item) => ! $item instanceof Model) ? $result->toBase() : $result; + } + + /** + * Reload a fresh model instance from the database for all the entities. + * + * @param array|string $with + * @return static + */ + public function fresh($with = []) + { + if ($this->isEmpty()) { + return new static(); + } + + $model = $this->first(); + + // @phpstan-ignore method.notFound (getDictionary is on Eloquent\Collection; PHPStan loses type through chain) + $freshModels = $model->newQueryWithoutScopes() + ->with(is_string($with) ? func_get_args() : $with) + ->whereIn($model->getKeyName(), $this->modelKeys()) + ->get() + ->getDictionary(); + + // @phpstan-ignore return.type (filter/map chain returns correct type at runtime) + return $this->filter(fn ($model) => $model->exists && isset($freshModels[$model->getKey()])) + ->map(fn ($model) => $freshModels[$model->getKey()]); + } + + /** + * Diff the collection with the given items. + * + * @param iterable $items + */ + public function diff($items): static + { + $diff = new static(); + + $dictionary = $this->getDictionary($items); + + foreach ($this->items as $item) { + if (! isset($dictionary[$this->getDictionaryKey($item->getKey())])) { + // @phpstan-ignore method.notFound (new static loses template types) + $diff->add($item); + } + } + + return $diff; + } + + /** + * Intersect the collection with the given items. + * + * @param iterable $items + */ + public function intersect(mixed $items): static + { + $intersect = new static(); + + if (empty($items)) { + return $intersect; + } + + $dictionary = $this->getDictionary($items); + + foreach ($this->items as $item) { + if (isset($dictionary[$this->getDictionaryKey($item->getKey())])) { + // @phpstan-ignore method.notFound (new static loses template types) + $intersect->add($item); + } + } + + return $intersect; + } + + /** + * Return only unique items from the collection. + * + * @param null|(callable(TModel, TKey): mixed)|string $key + */ + public function unique(mixed $key = null, bool $strict = false): static + { + if (! is_null($key)) { + return parent::unique($key, $strict); + } + + return new static(array_values($this->getDictionary())); + } + + /** + * Returns only the models from the collection with the specified keys. + * + * @param null|array $keys + */ + public function only($keys): static + { + if (is_null($keys)) { + // @phpstan-ignore return.type (new static preserves TModel at runtime) + return new static($this->items); + } + + $dictionary = Arr::only($this->getDictionary(), array_map($this->getDictionaryKey(...), (array) $keys)); + + // @phpstan-ignore return.type (new static preserves TModel at runtime) + return new static(array_values($dictionary)); + } + + /** + * Returns all models in the collection except the models with specified keys. + * + * @param null|array $keys + */ + public function except($keys): static + { + if (is_null($keys)) { + // @phpstan-ignore return.type (new static preserves TModel at runtime) + return new static($this->items); + } + + $dictionary = Arr::except($this->getDictionary(), array_map($this->getDictionaryKey(...), (array) $keys)); + + // @phpstan-ignore return.type (new static preserves TModel at runtime) + return new static(array_values($dictionary)); + } + + /** + * Make the given, typically visible, attributes hidden across the entire collection. + * + * @param array|string $attributes + * @return $this + */ + public function makeHidden($attributes) + { + // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) + return $this->each->makeHidden($attributes); + } + + /** + * Merge the given, typically visible, attributes hidden across the entire collection. + * + * @param array|string $attributes + * @return $this + */ + public function mergeHidden($attributes) + { + // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) + return $this->each->mergeHidden($attributes); + } + + /** + * Set the hidden attributes across the entire collection. + * + * @param array $hidden + * @return $this + */ + public function setHidden($hidden) + { + // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) + return $this->each->setHidden($hidden); + } + + /** + * Make the given, typically hidden, attributes visible across the entire collection. + * + * @param array|string $attributes + * @return $this + */ + public function makeVisible($attributes) + { + // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) + return $this->each->makeVisible($attributes); + } + + /** + * Merge the given, typically hidden, attributes visible across the entire collection. + * + * @param array|string $attributes + * @return $this + */ + public function mergeVisible($attributes) + { + // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) + return $this->each->mergeVisible($attributes); + } + + /** + * Set the visible attributes across the entire collection. + * + * @param array $visible + * @return $this + */ + public function setVisible($visible) + { + // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) + return $this->each->setVisible($visible); + } + + /** + * Append an attribute across the entire collection. + * + * @param array|string $attributes + * @return $this + */ + public function append($attributes) + { + // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) + return $this->each->append($attributes); + } + + /** + * 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) + { + // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) + return $this->each->setAppends($appends); + } + + /** + * Remove appended properties from every element in the collection. + * + * @return $this + */ + public function withoutAppends() + { + return $this->setAppends([]); + } + + /** + * Get a dictionary keyed by primary keys. + * + * @param null|iterable $items + * @return array + */ + public function getDictionary($items = null) + { + $items = is_null($items) ? $this->items : $items; + + $dictionary = []; + + foreach ($items as $value) { + $dictionary[$this->getDictionaryKey($value->getKey())] = $value; + } + + return $dictionary; + } + + /** + * The following methods are intercepted to always return base collections. + */ + + /** + * @return \Hypervel\Support\Collection + */ + #[Override] + public function countBy(callable|string|null $countBy = null) + { + return $this->toBase()->countBy($countBy); + } + + /** + * @return \Hypervel\Support\Collection + */ + #[Override] + public function collapse() + { + return $this->toBase()->collapse(); + } + + /** + * @return \Hypervel\Support\Collection + */ + #[Override] + public function flatten(int|float $depth = INF) + { + return $this->toBase()->flatten($depth); + } + + /** + * @return \Hypervel\Support\Collection + */ + #[Override] + public function flip() + { + return $this->toBase()->flip(); + } + + /** + * @return \Hypervel\Support\Collection + */ + #[Override] + public function keys() + { + return $this->toBase()->keys(); + } + + /** + * @template TPadValue + * + * @return \Hypervel\Support\Collection + */ + #[Override] + public function pad(int $size, mixed $value) + { + return $this->toBase()->pad($size, $value); + } + + /** + * @return \Hypervel\Support\Collection, static> + * @phpstan-ignore return.phpDocType (partition returns Collection of collections) + */ + #[Override] + public function partition(mixed $key, mixed $operator = null, mixed $value = null) + { + // @phpstan-ignore return.type (parent returns Hyperf Collection, we convert to Support Collection) + return parent::partition(...func_get_args())->toBase(); + } + + /** + * @return \Hypervel\Support\Collection + */ + #[Override] + public function pluck(Closure|string|int|array|null $value, Closure|string|null $key = null) + { + return $this->toBase()->pluck($value, $key); + } + + /** + * @template TZipValue + * + * @return \Hypervel\Support\Collection> + */ + #[Override] + public function zip(\Hypervel\Contracts\Support\Arrayable|iterable ...$items) + { + return $this->toBase()->zip(...$items); + } + + /** + * Get the comparison function to detect duplicates. + * + * @return callable(TModel, TModel): bool + */ + protected function duplicateComparator(bool $strict): callable + { + return fn ($a, $b) => $a->is($b); + } + + /** + * Enable relationship autoloading for all models in this collection. + * + * @return $this + */ + public function withRelationshipAutoloading() + { + $callback = fn ($tuples) => $this->loadMissingRelationshipChain($tuples); + + foreach ($this as $model) { + if (! $model->hasRelationAutoloadCallback()) { + $model->autoloadRelationsUsing($callback, $this); + } + } + + return $this; + } + + /** + * Get the type of the entities being queued. + * + * @throws LogicException + */ + public function getQueueableClass(): ?string + { + if ($this->isEmpty()) { + return null; + } + + $class = $this->getQueueableModelClass($this->first()); + + $this->each(function ($model) use ($class) { + if ($this->getQueueableModelClass($model) !== $class) { + throw new LogicException('Queueing collections with multiple model types is not supported.'); + } + }); + + return $class; + } + + /** + * Get the queueable class name for the given model. + * + * @param \Hypervel\Database\Eloquent\Model $model + * @return string + */ + protected function getQueueableModelClass($model) + { + return method_exists($model, 'getQueueableClassName') + ? $model->getQueueableClassName() + : get_class($model); + } + + /** + * Get the identifiers for all of the entities. + * + * @return array + */ + public function getQueueableIds(): array + { + if ($this->isEmpty()) { + return []; + } + + return $this->map->getQueueableId()->all(); + } + + /** + * Get the relationships of the entities being queued. + * + * @return array + */ + public function getQueueableRelations(): array + { + if ($this->isEmpty()) { + return []; + } + + // @phpstan-ignore method.nonObject (HigherOrderProxy returns Collection, not array) + $relations = $this->map->getQueueableRelations()->all(); + + if (count($relations) === 0 || $relations === [[]]) { + return []; + } + if (count($relations) === 1) { + return reset($relations); + } + return array_intersect(...array_values($relations)); + } + + /** + * Get the connection of the entities being queued. + * + * @throws LogicException + */ + public function getQueueableConnection(): ?string + { + if ($this->isEmpty()) { + return null; + } + + $connection = $this->first()->getConnectionName(); + + $this->each(function ($model) use ($connection) { + if ($model->getConnectionName() !== $connection) { + throw new LogicException('Queueing collections with multiple model connections is not supported.'); + } + }); + + return $connection; + } + + /** + * Get the Eloquent query builder from the collection. + * + * @return \Hypervel\Database\Eloquent\Builder + * + * @throws LogicException + */ + public function toQuery() + { + $model = $this->first(); + + if (! $model) { + throw new LogicException('Unable to create query for empty collection.'); + } + + $class = get_class($model); + + if ($this->reject(fn ($model) => $model instanceof $class)->isNotEmpty()) { + throw new LogicException('Unable to create query for collection with mixed types.'); + } + + return $model->newModelQuery()->whereKey($this->modelKeys()); + } +} diff --git a/src/database/src/Eloquent/Concerns/GuardsAttributes.php b/src/database/src/Eloquent/Concerns/GuardsAttributes.php new file mode 100644 index 000000000..fd95f396b --- /dev/null +++ b/src/database/src/Eloquent/Concerns/GuardsAttributes.php @@ -0,0 +1,243 @@ + + */ + protected array $fillable = []; + + /** + * The attributes that aren't mass assignable. + * + * @var array + */ + protected array $guarded = ['*']; + + /** + * The actual columns that exist on the database and can be guarded. + * + * @var array> + */ + protected static array $guardableColumns = []; + + /** + * Get the fillable attributes for the model. + * + * @return array + */ + public function getFillable(): array + { + return $this->fillable; + } + + /** + * Set the fillable attributes for the model. + * + * @param array $fillable + */ + public function fillable(array $fillable): static + { + $this->fillable = $fillable; + + return $this; + } + + /** + * Merge new fillable attributes with existing fillable attributes on the model. + * + * @param array $fillable + */ + public function mergeFillable(array $fillable): static + { + $this->fillable = array_values(array_unique(array_merge($this->fillable, $fillable))); + + return $this; + } + + /** + * Get the guarded attributes for the model. + * + * @return array + */ + public function getGuarded(): array + { + return static::isUnguarded() + ? [] + : $this->guarded; + } + + /** + * Set the guarded attributes for the model. + * + * @param array $guarded + */ + public function guard(array $guarded): static + { + $this->guarded = $guarded; + + return $this; + } + + /** + * Merge new guarded attributes with existing guarded attributes on the model. + * + * @param array $guarded + */ + public function mergeGuarded(array $guarded): static + { + $this->guarded = array_values(array_unique(array_merge($this->guarded, $guarded))); + + return $this; + } + + /** + * Disable all mass assignable restrictions. + * + * Uses Context for coroutine-safe state management. + */ + public static function unguard(bool $state = true): void + { + Context::set(self::UNGUARDED_CONTEXT_KEY, $state); + } + + /** + * Enable the mass assignment restrictions. + */ + public static function reguard(): void + { + Context::set(self::UNGUARDED_CONTEXT_KEY, false); + } + + /** + * Determine if the current state is "unguarded". + */ + public static function isUnguarded(): bool + { + return (bool) Context::get(self::UNGUARDED_CONTEXT_KEY, false); + } + + /** + * Run the given callable while being unguarded. + * + * Uses Context for coroutine-safe state management, ensuring concurrent + * requests don't interfere with each other's guarding state. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn + */ + public static function unguarded(callable $callback): mixed + { + if (static::isUnguarded()) { + return $callback(); + } + + $wasUnguarded = Context::get(self::UNGUARDED_CONTEXT_KEY, false); + Context::set(self::UNGUARDED_CONTEXT_KEY, true); + + try { + return $callback(); + } finally { + Context::set(self::UNGUARDED_CONTEXT_KEY, $wasUnguarded); + } + } + + /** + * Determine if the given attribute may be mass assigned. + */ + public function isFillable(string $key): bool + { + if (static::isUnguarded()) { + return true; + } + + // If the key is in the "fillable" array, we can of course assume that it's + // a fillable attribute. Otherwise, we will check the guarded array when + // we need to determine if the attribute is black-listed on the model. + if (in_array($key, $this->getFillable())) { + return true; + } + + // If the attribute is explicitly listed in the "guarded" array then we can + // return false immediately. This means this attribute is definitely not + // fillable and there is no point in going any further in this method. + if ($this->isGuarded($key)) { + return false; + } + + return empty($this->getFillable()) + && ! str_contains($key, '.') + && ! str_starts_with($key, '_'); + } + + /** + * Determine if the given key is guarded. + */ + public function isGuarded(string $key): bool + { + if (empty($this->getGuarded())) { + return false; + } + + return $this->getGuarded() == ['*'] + || ! empty(preg_grep('/^' . preg_quote($key, '/') . '$/i', $this->getGuarded())) + || ! $this->isGuardableColumn($key); + } + + /** + * Determine if the given column is a valid, guardable column. + */ + protected function isGuardableColumn(string $key): bool + { + if ($this->hasSetMutator($key) || $this->hasAttributeSetMutator($key) || $this->isClassCastable($key)) { + return true; + } + + if (! isset(static::$guardableColumns[get_class($this)])) { + $columns = $this->getConnection() + ->getSchemaBuilder() + ->getColumnListing($this->getTable()); + + if (empty($columns)) { + return true; + } + + static::$guardableColumns[get_class($this)] = $columns; + } + + return in_array($key, static::$guardableColumns[get_class($this)]); + } + + /** + * Determine if the model is totally guarded. + */ + public function totallyGuarded(): bool + { + return count($this->getFillable()) === 0 && $this->getGuarded() == ['*']; + } + + /** + * Get the fillable attributes of a given array. + * + * @param array $attributes + * @return array + */ + protected function fillableFromArray(array $attributes): array + { + if (count($this->getFillable()) > 0 && ! static::isUnguarded()) { + return array_intersect_key($attributes, array_flip($this->getFillable())); + } + + return $attributes; + } +} diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php new file mode 100644 index 000000000..08b509440 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -0,0 +1,2229 @@ + + */ + protected array $attributes = []; + + /** + * The model attribute's original state. + * + * @var array + */ + protected array $original = []; + + /** + * The changed model attributes. + * + * @var array + */ + protected array $changes = []; + + /** + * The previous state of the changed model attributes. + * + * @var array + */ + protected array $previous = []; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected array $casts = []; + + /** + * The attributes that have been cast using custom classes. + */ + protected array $classCastCache = []; + + /** + * The attributes that have been cast using "Attribute" return type mutators. + */ + protected array $attributeCastCache = []; + + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static array $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'encrypted', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + 'float', + 'hashed', + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime', + 'int', + 'integer', + 'json', + 'json:unicode', + 'object', + 'real', + 'string', + 'timestamp', + ]; + + /** + * The storage format of the model's date columns. + */ + protected ?string $dateFormat = null; + + /** + * The accessors to append to the model's array form. + */ + protected array $appends = []; + + /** + * Indicates whether attributes are snake cased on arrays. + */ + public static bool $snakeAttributes = true; + + /** + * The cache of the mutated attributes for each class. + */ + protected static array $mutatorCache = []; + + /** + * The cache of the "Attribute" return type marked mutated attributes for each class. + */ + protected static array $attributeMutatorCache = []; + + /** + * The cache of the "Attribute" return type marked mutated, gettable attributes for each class. + */ + protected static array $getAttributeMutatorCache = []; + + /** + * The cache of the "Attribute" return type marked mutated, settable attributes for each class. + */ + protected static array $setAttributeMutatorCache = []; + + /** + * The cache of the converted cast types. + */ + protected static array $castTypeCache = []; + + /** + * The encrypter instance that is used to encrypt attributes. + * + * @var null|\Hypervel\Contracts\Encryption\Encrypter + */ + public static mixed $encrypter = null; + + /** + * Initialize the trait. + */ + protected function initializeHasAttributes(): void + { + $this->casts = $this->ensureCastsAreStringValues( + array_merge($this->casts, $this->casts()), + ); + } + + /** + * Convert the model's attributes to an array. + * + * @return array + */ + public function attributesToArray(): array + { + // If an attribute is a date, we will cast it to a string after converting it + // to a DateTime / Carbon instance. This is so we will get some consistent + // formatting while accessing attributes vs. arraying / JSONing a model. + $attributes = $this->addDateAttributesToArray( + $attributes = $this->getArrayableAttributes() + ); + + $attributes = $this->addMutatedAttributesToArray( + $attributes, + $mutatedAttributes = $this->getMutatedAttributes() + ); + + // Next we will handle any casts that have been setup for this model and cast + // the values to their appropriate type. If the attribute has a mutator we + // will not perform the cast on those attributes to avoid any confusion. + $attributes = $this->addCastAttributesToArray( + $attributes, + $mutatedAttributes + ); + + // Here we will grab all of the appended, calculated attributes to this model + // as these attributes are not really in the attributes array, but are run + // when we need to array or JSON the model for convenience to the coder. + foreach ($this->getArrayableAppends() as $key) { + $attributes[$key] = $this->mutateAttributeForArray($key, null); + } + + return $attributes; + } + + /** + * Add the date attributes to the attributes array. + * + * @param array $attributes + * @return array + */ + protected function addDateAttributesToArray(array $attributes): array + { + foreach ($this->getDates() as $key) { + if (is_null($key) || ! isset($attributes[$key])) { + continue; + } + + $attributes[$key] = $this->serializeDate( + $this->asDateTime($attributes[$key]) + ); + } + + return $attributes; + } + + /** + * Add the mutated attributes to the attributes array. + * + * @param array $attributes + * @param array $mutatedAttributes + * @return array + */ + protected function addMutatedAttributesToArray(array $attributes, array $mutatedAttributes): array + { + foreach ($mutatedAttributes as $key) { + // We want to spin through all the mutated attributes for this model and call + // the mutator for the attribute. We cache off every mutated attributes so + // we don't have to constantly check on attributes that actually change. + if (! array_key_exists($key, $attributes)) { + continue; + } + + // Next, we will call the mutator for this attribute so that we can get these + // mutated attribute's actual values. After we finish mutating each of the + // attributes we will return this final array of the mutated attributes. + $attributes[$key] = $this->mutateAttributeForArray( + $key, + $attributes[$key] + ); + } + + return $attributes; + } + + /** + * Add the casted attributes to the attributes array. + * + * @param array $attributes + * @param array $mutatedAttributes + * @return array + */ + protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes): array + { + foreach ($this->getCasts() as $key => $value) { + if (! array_key_exists($key, $attributes) + || in_array($key, $mutatedAttributes)) { + continue; + } + + // Here we will cast the attribute. Then, if the cast is a date or datetime cast + // then we will serialize the date for the array. This will convert the dates + // to strings based on the date format specified for these Eloquent models. + $attributes[$key] = $this->castAttribute( + $key, + $attributes[$key] + ); + + // If the attribute cast was a date or a datetime, we will serialize the date as + // a string. This allows the developers to customize how dates are serialized + // into an array without affecting how they are persisted into the storage. + if (isset($attributes[$key]) && in_array($value, ['date', 'datetime', 'immutable_date', 'immutable_datetime'])) { + $attributes[$key] = $this->serializeDate($attributes[$key]); + } + + if (isset($attributes[$key]) && ($this->isCustomDateTimeCast($value) + || $this->isImmutableCustomDateTimeCast($value))) { + $attributes[$key] = $attributes[$key]->format(explode(':', $value, 2)[1]); + } + + if ($attributes[$key] instanceof DateTimeInterface + && $this->isClassCastable($key)) { + $attributes[$key] = $this->serializeDate($attributes[$key]); + } + + if (isset($attributes[$key]) && $this->isClassSerializable($key)) { + $attributes[$key] = $this->serializeClassCastableAttribute($key, $attributes[$key]); + } + + if ($this->isEnumCastable($key) && (! ($attributes[$key] ?? null) instanceof Arrayable)) { + $attributes[$key] = isset($attributes[$key]) ? $this->getStorableEnumValue($this->getCasts()[$key], $attributes[$key]) : null; + } + + if ($attributes[$key] instanceof Arrayable) { + $attributes[$key] = $attributes[$key]->toArray(); + } + } + + return $attributes; + } + + /** + * Get an attribute array of all arrayable attributes. + * + * @return array + */ + protected function getArrayableAttributes(): array + { + return $this->getArrayableItems($this->getAttributes()); + } + + /** + * Get all of the appendable values that are arrayable. + */ + protected function getArrayableAppends(): array + { + if (! count($this->appends)) { + return []; + } + + return $this->getArrayableItems( + array_combine($this->appends, $this->appends) + ); + } + + /** + * Get the model's relationships in array form. + */ + public function relationsToArray(): array + { + $attributes = []; + + foreach ($this->getArrayableRelations() as $key => $value) { + // If the values implement the Arrayable interface we can just call this + // toArray method on the instances which will convert both models and + // collections to their proper array form and we'll set the values. + if ($value instanceof Arrayable) { + $relation = $value->toArray(); + } + + // If the value is null, we'll still go ahead and set it in this list of + // attributes, since null is used to represent empty relationships if + // it has a has one or belongs to type relationships on the models. + elseif (is_null($value)) { + $relation = $value; + } + + // If the relationships snake-casing is enabled, we will snake case this + // key so that the relation attribute is snake cased in this returned + // array to the developers, making this consistent with attributes. + if (static::$snakeAttributes) { + $key = StrCache::snake($key); + } + + // If the relation value has been set, we will set it on this attributes + // list for returning. If it was not arrayable or null, we'll not set + // the value on the array because it is some type of invalid value. + if (array_key_exists('relation', get_defined_vars())) { // check if $relation is in scope (could be null) + $attributes[$key] = $relation ?? null; + } + + unset($relation); + } + + return $attributes; + } + + /** + * Get an attribute array of all arrayable relations. + */ + protected function getArrayableRelations(): array + { + return $this->getArrayableItems($this->relations); + } + + /** + * Get an attribute array of all arrayable values. + */ + protected function getArrayableItems(array $values): array + { + if (count($this->getVisible()) > 0) { + $values = array_intersect_key($values, array_flip($this->getVisible())); + } + + if (count($this->getHidden()) > 0) { + $values = array_diff_key($values, array_flip($this->getHidden())); + } + + return $values; + } + + /** + * Determine whether an attribute exists on the model. + */ + public function hasAttribute(string $key): bool + { + if (! $key) { + return false; + } + + return array_key_exists($key, $this->attributes) + || array_key_exists($key, $this->casts) + || $this->hasGetMutator($key) + || $this->hasAttributeMutator($key) + || $this->isClassCastable($key); + } + + /** + * Get an attribute from the model. + */ + public function getAttribute(string $key): mixed + { + if (! $key) { + return null; + } + + // If the attribute exists in the attribute array or has a "get" mutator we will + // get the attribute's value. Otherwise, we will proceed as if the developers + // are asking for a relationship's value. This covers both types of values. + if ($this->hasAttribute($key)) { + return $this->getAttributeValue($key); + } + + // Here we will determine if the model base class itself contains this given key + // since we don't want to treat any of those methods as relationships because + // they are all intended as helper methods and none of these are relations. + if (method_exists(self::class, $key)) { + return $this->throwMissingAttributeExceptionIfApplicable($key); + } + + return $this->isRelation($key) || $this->relationLoaded($key) + ? $this->getRelationValue($key) + : $this->throwMissingAttributeExceptionIfApplicable($key); + } + + /** + * Either throw a missing attribute exception or return null depending on Eloquent's configuration. + * + * @throws \Hypervel\Database\Eloquent\MissingAttributeException + */ + protected function throwMissingAttributeExceptionIfApplicable(string $key): mixed + { + if ($this->exists + && ! $this->wasRecentlyCreated + && static::preventsAccessingMissingAttributes()) { + if (isset(static::$missingAttributeViolationCallback)) { + return call_user_func(static::$missingAttributeViolationCallback, $this, $key); + } + + throw new MissingAttributeException($this, $key); + } + + return null; + } + + /** + * Get a plain attribute (not a relationship). + */ + public function getAttributeValue(string $key): mixed + { + return $this->transformModelValue($key, $this->getAttributeFromArray($key)); + } + + /** + * Get an attribute from the $attributes array. + */ + protected function getAttributeFromArray(string $key): mixed + { + return $this->getAttributes()[$key] ?? null; + } + + /** + * Get a relationship. + */ + public function getRelationValue(string $key): mixed + { + // If the key already exists in the relationships array, it just means the + // relationship has already been loaded, so we'll just return it out of + // here because there is no need to query within the relations twice. + if ($this->relationLoaded($key)) { + return $this->relations[$key]; + } + + if (! $this->isRelation($key)) { + return null; + } + + if ($this->attemptToAutoloadRelation($key)) { + return $this->relations[$key]; + } + + if ($this->preventsLazyLoading) { + $this->handleLazyLoadingViolation($key); + } + + // If the "attribute" exists as a method on the model, we will just assume + // it is a relationship and will load and return results from the query + // and hydrate the relationship's value on the "relationships" array. + return $this->getRelationshipFromMethod($key); + } + + /** + * Determine if the given key is a relationship method on the model. + */ + public function isRelation(string $key): bool + { + if ($this->hasAttributeMutator($key)) { + return false; + } + + return method_exists($this, $key) + || $this->relationResolver(static::class, $key); + } + + /** + * Handle a lazy loading violation. + */ + protected function handleLazyLoadingViolation(string $key): mixed + { + if (isset(static::$lazyLoadingViolationCallback)) { + return call_user_func(static::$lazyLoadingViolationCallback, $this, $key); + } + + if (! $this->exists || $this->wasRecentlyCreated) { + return null; + } + + throw new LazyLoadingViolationException($this, $key); + } + + /** + * Get a relationship value from a method. + * + * @throws LogicException + */ + protected function getRelationshipFromMethod(string $method): mixed + { + $relation = $this->{$method}(); + + if (! $relation instanceof Relation) { + if (is_null($relation)) { + throw new LogicException(sprintf( + '%s::%s must return a relationship instance, but "null" was returned. Was the "return" keyword used?', + static::class, + $method + )); + } + + throw new LogicException(sprintf( + '%s::%s must return a relationship instance.', + static::class, + $method + )); + } + + return tap($relation->getResults(), function ($results) use ($method) { + $this->setRelation($method, $results); + }); + } + + /** + * Determine if a get mutator exists for an attribute. + */ + public function hasGetMutator(string $key): bool + { + return method_exists($this, 'get' . StrCache::studly($key) . 'Attribute'); + } + + /** + * Determine if a "Attribute" return type marked mutator exists for an attribute. + */ + public function hasAttributeMutator(string $key): bool + { + if (isset(static::$attributeMutatorCache[get_class($this)][$key])) { + return static::$attributeMutatorCache[get_class($this)][$key]; + } + + if (! method_exists($this, $method = StrCache::camel($key))) { + return static::$attributeMutatorCache[get_class($this)][$key] = false; + } + + $returnType = (new ReflectionMethod($this, $method))->getReturnType(); + + return static::$attributeMutatorCache[get_class($this)][$key] + = $returnType instanceof ReflectionNamedType + && $returnType->getName() === Attribute::class; + } + + /** + * Determine if a "Attribute" return type marked get mutator exists for an attribute. + */ + public function hasAttributeGetMutator(string $key): bool + { + if (isset(static::$getAttributeMutatorCache[get_class($this)][$key])) { + return static::$getAttributeMutatorCache[get_class($this)][$key]; + } + + if (! $this->hasAttributeMutator($key)) { + return static::$getAttributeMutatorCache[get_class($this)][$key] = false; + } + + return static::$getAttributeMutatorCache[get_class($this)][$key] = is_callable($this->{StrCache::camel($key)}()->get); + } + + /** + * Determine if any get mutator exists for an attribute. + */ + public function hasAnyGetMutator(string $key): bool + { + return $this->hasGetMutator($key) || $this->hasAttributeGetMutator($key); + } + + /** + * Get the value of an attribute using its mutator. + */ + protected function mutateAttribute(string $key, mixed $value): mixed + { + return $this->{'get' . StrCache::studly($key) . 'Attribute'}($value); + } + + /** + * Get the value of an "Attribute" return type marked attribute using its mutator. + */ + protected function mutateAttributeMarkedAttribute(string $key, mixed $value): mixed + { + if (array_key_exists($key, $this->attributeCastCache)) { + return $this->attributeCastCache[$key]; + } + + $attribute = $this->{StrCache::camel($key)}(); + + $value = call_user_func($attribute->get ?: function ($value) { + return $value; + }, $value, $this->attributes); + + if ($attribute->withCaching || (is_object($value) && $attribute->withObjectCaching)) { + $this->attributeCastCache[$key] = $value; + } else { + unset($this->attributeCastCache[$key]); + } + + return $value; + } + + /** + * Get the value of an attribute using its mutator for array conversion. + */ + protected function mutateAttributeForArray(string $key, mixed $value): mixed + { + if ($this->isClassCastable($key)) { + $value = $this->getClassCastableAttributeValue($key, $value); + } elseif (isset(static::$getAttributeMutatorCache[get_class($this)][$key]) + && static::$getAttributeMutatorCache[get_class($this)][$key] === true) { + $value = $this->mutateAttributeMarkedAttribute($key, $value); + + $value = $value instanceof DateTimeInterface + ? $this->serializeDate($value) + : $value; + } else { + $value = $this->mutateAttribute($key, $value); + } + + return $value instanceof Arrayable ? $value->toArray() : $value; + } + + /** + * Merge new casts with existing casts on the model. + */ + public function mergeCasts(array $casts): static + { + $casts = $this->ensureCastsAreStringValues($casts); + + $this->casts = array_merge($this->casts, $casts); + + return $this; + } + + /** + * Ensure that the given casts are strings. + */ + protected function ensureCastsAreStringValues(array $casts): array + { + foreach ($casts as $attribute => $cast) { + $casts[$attribute] = match (true) { + is_object($cast) => value(function () use ($cast, $attribute) { + if ($cast instanceof Stringable) { + return (string) $cast; + } + + throw new InvalidArgumentException( + "The cast object for the {$attribute} attribute must implement Stringable." + ); + }), + is_array($cast) => value(function () use ($cast) { + if (count($cast) === 1) { + return $cast[0]; + } + + [$cast, $arguments] = [array_shift($cast), $cast]; + + return $cast . ':' . implode(',', $arguments); + }), + default => $cast, + }; + } + + return $casts; + } + + /** + * Cast an attribute to a native PHP type. + */ + protected function castAttribute(string $key, mixed $value): mixed + { + $castType = $this->getCastType($key); + + if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) { + return $value; + } + + // If the key is one of the encrypted castable types, we'll first decrypt + // the value and update the cast type so we may leverage the following + // logic for casting this value to any additionally specified types. + if ($this->isEncryptedCastable($key)) { + $value = $this->fromEncryptedString($value); + + $castType = Str::after($castType, 'encrypted:'); + } + + switch ($castType) { + case 'int': + case 'integer': + return (int) $value; + case 'real': + case 'float': + case 'double': + return $this->fromFloat($value); + case 'decimal': + return $this->asDecimal($value, (int) explode(':', $this->getCasts()[$key], 2)[1]); + case 'string': + return (string) $value; + case 'bool': + case 'boolean': + return (bool) $value; + case 'object': + return $this->fromJson($value, true); + case 'array': + case 'json': + case 'json:unicode': + return $this->fromJson($value); + case 'collection': + return new BaseCollection($this->fromJson($value)); + case 'date': + return $this->asDate($value); + case 'datetime': + case 'custom_datetime': + return $this->asDateTime($value); + case 'immutable_date': + return $this->asDate($value)->toImmutable(); + case 'immutable_custom_datetime': + case 'immutable_datetime': + return $this->asDateTime($value)->toImmutable(); + case 'timestamp': + return $this->asTimestamp($value); + } + + if ($this->isEnumCastable($key)) { + return $this->getEnumCastableAttributeValue($key, $value); + } + + if ($this->isClassCastable($key)) { + return $this->getClassCastableAttributeValue($key, $value); + } + + return $value; + } + + /** + * Cast the given attribute using a custom cast class. + */ + protected function getClassCastableAttributeValue(string $key, mixed $value): mixed + { + $caster = $this->resolveCasterClass($key); + + $objectCachingDisabled = $caster->withoutObjectCaching ?? false; + + if (isset($this->classCastCache[$key]) && ! $objectCachingDisabled) { + return $this->classCastCache[$key]; + } + $value = $caster instanceof CastsInboundAttributes + ? $value + : $caster->get($this, $key, $value, $this->attributes); + + if ($caster instanceof CastsInboundAttributes + || ! is_object($value) + || $objectCachingDisabled) { + unset($this->classCastCache[$key]); + } else { + $this->classCastCache[$key] = $value; + } + + return $value; + } + + /** + * Cast the given attribute to an enum. + */ + protected function getEnumCastableAttributeValue(string $key, mixed $value): mixed + { + if (is_null($value)) { + return null; + } + + $castType = $this->getCasts()[$key]; + + if ($value instanceof $castType) { + return $value; + } + + return $this->getEnumCaseFromValue($castType, $value); + } + + /** + * Get the type of cast for a model attribute. + */ + protected function getCastType(string $key): string + { + $castType = $this->getCasts()[$key]; + + if (isset(static::$castTypeCache[$castType])) { + return static::$castTypeCache[$castType]; + } + + if ($this->isCustomDateTimeCast($castType)) { + $convertedCastType = 'custom_datetime'; + } elseif ($this->isImmutableCustomDateTimeCast($castType)) { + $convertedCastType = 'immutable_custom_datetime'; + } elseif ($this->isDecimalCast($castType)) { + $convertedCastType = 'decimal'; + } elseif (class_exists($castType)) { + $convertedCastType = $castType; + } else { + $convertedCastType = trim(strtolower($castType)); + } + + return static::$castTypeCache[$castType] = $convertedCastType; + } + + /** + * Increment or decrement the given attribute using the custom cast class. + */ + protected function deviateClassCastableAttribute(string $method, string $key, mixed $value): mixed + { + return $this->resolveCasterClass($key)->{$method}( + $this, + $key, + $value, + $this->attributes + ); + } + + /** + * Serialize the given attribute using the custom cast class. + */ + protected function serializeClassCastableAttribute(string $key, mixed $value): mixed + { + return $this->resolveCasterClass($key)->serialize( + $this, + $key, + $value, + $this->attributes + ); + } + + /** + * Compare two values for the given attribute using the custom cast class. + */ + protected function compareClassCastableAttribute(string $key, mixed $original, mixed $value): bool + { + return $this->resolveCasterClass($key)->compare( + $this, + $key, + $original, + $value + ); + } + + /** + * Determine if the cast type is a custom date time cast. + */ + protected function isCustomDateTimeCast(string $cast): bool + { + return str_starts_with($cast, 'date:') + || str_starts_with($cast, 'datetime:'); + } + + /** + * Determine if the cast type is an immutable custom date time cast. + */ + protected function isImmutableCustomDateTimeCast(string $cast): bool + { + return str_starts_with($cast, 'immutable_date:') + || str_starts_with($cast, 'immutable_datetime:'); + } + + /** + * Determine if the cast type is a decimal cast. + */ + protected function isDecimalCast(string $cast): bool + { + return str_starts_with($cast, 'decimal:'); + } + + /** + * Set a given attribute on the model. + */ + public function setAttribute(string|int $key, mixed $value): mixed + { + // Numeric keys cannot have mutators or casts, so store directly. + if (is_int($key)) { + $this->attributes[$key] = $value; + + return $this; + } + + // First we will check for the presence of a mutator for the set operation + // which simply lets the developers tweak the attribute as it is set on + // this model, such as "json_encoding" a listing of data for storage. + if ($this->hasSetMutator($key)) { + return $this->setMutatedAttributeValue($key, $value); + } + if ($this->hasAttributeSetMutator($key)) { + return $this->setAttributeMarkedMutatedAttributeValue($key, $value); + } + + // If an attribute is listed as a "date", we'll convert it from a DateTime + // instance into a form proper for storage on the database tables using + // the connection grammar's date format. We will auto set the values. + if (! is_null($value) && $this->isDateAttribute($key)) { + $value = $this->fromDateTime($value); + } + + if ($this->isEnumCastable($key)) { + $this->setEnumCastableAttribute($key, $value); + + return $this; + } + + if ($this->isClassCastable($key)) { + $this->setClassCastableAttribute($key, $value); + + return $this; + } + + if (! is_null($value) && $this->isJsonCastable($key)) { + $value = $this->castAttributeAsJson($key, $value); + } + + // If this attribute contains a JSON ->, we'll set the proper value in the + // attribute's underlying array. This takes care of properly nesting an + // attribute in the array's value in the case of deeply nested items. + if (str_contains($key, '->')) { + return $this->fillJsonAttribute($key, $value); + } + + if (! is_null($value) && $this->isEncryptedCastable($key)) { + $value = $this->castAttributeAsEncryptedString($key, $value); + } + + if (! is_null($value) && $this->hasCast($key, 'hashed')) { + $value = $this->castAttributeAsHashedString($key, $value); + } + + $this->attributes[$key] = $value; + + return $this; + } + + /** + * Determine if a set mutator exists for an attribute. + */ + public function hasSetMutator(string $key): bool + { + return method_exists($this, 'set' . StrCache::studly($key) . 'Attribute'); + } + + /** + * Determine if an "Attribute" return type marked set mutator exists for an attribute. + */ + public function hasAttributeSetMutator(string $key): bool + { + $class = get_class($this); + + if (isset(static::$setAttributeMutatorCache[$class][$key])) { + return static::$setAttributeMutatorCache[$class][$key]; + } + + if (! method_exists($this, $method = StrCache::camel($key))) { + return static::$setAttributeMutatorCache[$class][$key] = false; + } + + $returnType = (new ReflectionMethod($this, $method))->getReturnType(); + + return static::$setAttributeMutatorCache[$class][$key] + = $returnType instanceof ReflectionNamedType + && $returnType->getName() === Attribute::class + && is_callable($this->{$method}()->set); + } + + /** + * Set the value of an attribute using its mutator. + */ + protected function setMutatedAttributeValue(string $key, mixed $value): mixed + { + return $this->{'set' . StrCache::studly($key) . 'Attribute'}($value); + } + + /** + * Set the value of a "Attribute" return type marked attribute using its mutator. + */ + protected function setAttributeMarkedMutatedAttributeValue(string $key, mixed $value): mixed + { + $attribute = $this->{StrCache::camel($key)}(); + + $callback = $attribute->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, + $callback($value, $this->attributes) + ) + ); + + if ($attribute->withCaching || (is_object($value) && $attribute->withObjectCaching)) { + $this->attributeCastCache[$key] = $value; + } else { + unset($this->attributeCastCache[$key]); + } + + return $this; + } + + /** + * Determine if the given attribute is a date or date castable. + */ + protected function isDateAttribute(string $key): bool + { + return in_array($key, $this->getDates(), true) + || $this->isDateCastable($key); + } + + /** + * Set a given JSON attribute on the model. + */ + public function fillJsonAttribute(string $key, mixed $value): static + { + [$key, $path] = explode('->', $key, 2); + + $value = $this->asJson($this->getArrayAttributeWithValue( + $path, + $key, + $value + ), $this->getJsonCastFlags($key)); + + $this->attributes[$key] = $this->isEncryptedCastable($key) + ? $this->castAttributeAsEncryptedString($key, $value) + : $value; + + if ($this->isClassCastable($key)) { + unset($this->classCastCache[$key]); + } + + return $this; + } + + /** + * Set the value of a class castable attribute. + */ + protected function setClassCastableAttribute(string $key, mixed $value): void + { + $caster = $this->resolveCasterClass($key); + + $this->attributes = array_replace( + $this->attributes, + $this->normalizeCastClassResponse($key, $caster->set( + $this, + $key, + $value, + $this->attributes + )) + ); + + if ($caster instanceof CastsInboundAttributes + || ! is_object($value) + || ($caster->withoutObjectCaching ?? false)) { + unset($this->classCastCache[$key]); + } else { + $this->classCastCache[$key] = $value; + } + } + + /** + * Set the value of an enum castable attribute. + * + * @param null|int|string|UnitEnum $value + */ + protected function setEnumCastableAttribute(string $key, mixed $value): void + { + $enumClass = $this->getCasts()[$key]; + + if (! isset($value)) { + $this->attributes[$key] = null; + } elseif (is_object($value)) { + $this->attributes[$key] = $this->getStorableEnumValue($enumClass, $value); + } else { + $this->attributes[$key] = $this->getStorableEnumValue( + $enumClass, + $this->getEnumCaseFromValue($enumClass, $value) + ); + } + } + + /** + * Get an enum case instance from a given class and value. + * + * @return BackedEnum|UnitEnum + */ + protected function getEnumCaseFromValue(string $enumClass, string|int $value): mixed + { + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($value) + : constant($enumClass . '::' . $value); + } + + /** + * Get the storable value from the given enum. + * + * @param BackedEnum|UnitEnum $value + */ + protected function getStorableEnumValue(string $expectedEnum, mixed $value): string|int + { + if (! $value instanceof $expectedEnum) { + throw new ValueError(sprintf('Value [%s] is not of the expected enum type [%s].', var_export($value, true), $expectedEnum)); + } + + return enum_value($value); + } + + /** + * Get an array attribute with the given key and value set. + */ + protected function getArrayAttributeWithValue(string $path, string $key, mixed $value): array + { + return tap($this->getArrayAttributeByKey($key), function (&$array) use ($path, $value) { + Arr::set($array, str_replace('->', '.', $path), $value); + }); + } + + /** + * Get an array attribute or return an empty array if it is not set. + */ + protected function getArrayAttributeByKey(string $key): array + { + if (! isset($this->attributes[$key])) { + return []; + } + + return $this->fromJson( + $this->isEncryptedCastable($key) + ? $this->fromEncryptedString($this->attributes[$key]) + : $this->attributes[$key] + ); + } + + /** + * Cast the given attribute to JSON. + */ + protected function castAttributeAsJson(string $key, mixed $value): string + { + $value = $this->asJson($value, $this->getJsonCastFlags($key)); + + if ($value === false) { + throw JsonEncodingException::forAttribute( + $this, + $key, + json_last_error_msg() + ); + } + + return $value; + } + + /** + * Get the JSON casting flags for the given attribute. + */ + protected function getJsonCastFlags(string $key): int + { + $flags = 0; + + if ($this->hasCast($key, ['json:unicode'])) { + $flags |= JSON_UNESCAPED_UNICODE; + } + + return $flags; + } + + /** + * Encode the given value as JSON. + */ + protected function asJson(mixed $value, int $flags = 0): string|false + { + return Json::encode($value, $flags); + } + + /** + * Decode the given JSON back into an array or object. + */ + public function fromJson(?string $value, bool $asObject = false): mixed + { + if ($value === null || $value === '') { + return null; + } + + return Json::decode($value, ! $asObject); + } + + /** + * Decrypt the given encrypted string. + */ + public function fromEncryptedString(string $value): mixed + { + return static::currentEncrypter()->decrypt($value, false); + } + + /** + * Cast the given attribute to an encrypted string. + */ + protected function castAttributeAsEncryptedString(string $key, #[SensitiveParameter] mixed $value): string + { + return static::currentEncrypter()->encrypt($value, false); + } + + /** + * Set the encrypter instance that will be used to encrypt attributes. + * + * @param null|\Hypervel\Contracts\Encryption\Encrypter $encrypter + */ + public static function encryptUsing(mixed $encrypter): void + { + static::$encrypter = $encrypter; + } + + /** + * Get the current encrypter being used by the model. + * + * @return \Hypervel\Contracts\Encryption\Encrypter + */ + public static function currentEncrypter(): mixed + { + return static::$encrypter ?? Crypt::getFacadeRoot(); + } + + /** + * Cast the given attribute to a hashed string. + */ + protected function castAttributeAsHashedString(string $key, #[SensitiveParameter] mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (! Hash::isHashed($value)) { + return Hash::make($value); + } + + /* @phpstan-ignore staticMethod.notFound */ + if (! Hash::verifyConfiguration($value)) { + throw new RuntimeException("Could not verify the hashed value's configuration."); + } + + return $value; + } + + /** + * Decode the given float. + */ + public function fromFloat(mixed $value): float + { + return match ((string) $value) { + 'Infinity' => INF, + '-Infinity' => -INF, + 'NaN' => NAN, + default => (float) $value, + }; + } + + /** + * Return a decimal as string. + */ + protected function asDecimal(float|string $value, int $decimals): string + { + try { + return (string) BigDecimal::of($value)->toScale($decimals, RoundingMode::HALF_UP); + } catch (BrickMathException $e) { + throw new MathException('Unable to cast value to a decimal.', previous: $e); + } + } + + /** + * Return a timestamp as DateTime object with time set to 00:00:00. + * + * @return \Hypervel\Support\Carbon + */ + protected function asDate(mixed $value): CarbonInterface + { + return $this->asDateTime($value)->startOfDay(); + } + + /** + * Return a timestamp as DateTime object. + * + * @return \Hypervel\Support\Carbon + */ + protected function asDateTime(mixed $value): CarbonInterface + { + // If this value is already a Carbon instance, we shall just return it as is. + // This prevents us having to re-instantiate a Carbon instance when we know + // it already is one, which wouldn't be fulfilled by the DateTime check. + if ($value instanceof CarbonInterface) { + return Date::instance($value); + } + + // If the value is already a DateTime instance, we will just skip the rest of + // these checks since they will be a waste of time, and hinder performance + // when checking the field. We will just return the DateTime right away. + if ($value instanceof DateTimeInterface) { + return Date::parse( + $value->format('Y-m-d H:i:s.u'), + $value->getTimezone() + ); + } + + // If this value is an integer, we will assume it is a UNIX timestamp's value + // and format a Carbon object from this timestamp. This allows flexibility + // when defining your date fields as they might be UNIX timestamps here. + if (is_numeric($value)) { + return Date::createFromTimestamp($value, date_default_timezone_get()); + } + + // If the value is in simply year, month, day format, we will instantiate the + // Carbon instances from that format. Again, this provides for simple date + // fields on the database, while still supporting Carbonized conversion. + if ($this->isStandardDateFormat($value)) { + return Date::instance(Carbon::createFromFormat('Y-m-d', $value)->startOfDay()); + } + + $format = $this->getDateFormat(); + + // Finally, we will just assume this date is in the format used by default on + // the database connection and use that format to create the Carbon object + // that is returned back out to the developers after we convert it here. + try { + $date = Date::createFromFormat($format, $value); + // @phpstan-ignore catch.neverThrown (defensive: some Carbon versions/configs may throw) + } catch (InvalidArgumentException) { + $date = false; + } + + return $date ?: Date::parse($value); + } + + /** + * Determine if the given value is a standard date format. + */ + protected function isStandardDateFormat(string $value): bool + { + return (bool) preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value); + } + + /** + * Convert a DateTime to a storable string. + */ + public function fromDateTime(mixed $value): ?string + { + return ($value === null || $value === '') ? $value : $this->asDateTime($value)->format( + $this->getDateFormat() + ); + } + + /** + * Return a timestamp as unix timestamp. + */ + protected function asTimestamp(mixed $value): int + { + return $this->asDateTime($value)->getTimestamp(); + } + + /** + * Prepare a date for array / JSON serialization. + */ + protected function serializeDate(DateTimeInterface $date): string + { + return $date instanceof DateTimeImmutable + ? CarbonImmutable::instance($date)->toJSON() + : Carbon::instance($date)->toJSON(); + } + + /** + * Get the attributes that should be converted to dates. + * + * @return array + */ + public function getDates(): array + { + return $this->usesTimestamps() ? [ + $this->getCreatedAtColumn(), + $this->getUpdatedAtColumn(), + ] : []; + } + + /** + * Get the format for database stored dates. + */ + public function getDateFormat(): string + { + return $this->dateFormat ?: $this->getConnection()->getQueryGrammar()->getDateFormat(); + } + + /** + * Set the date format used by the model. + */ + public function setDateFormat(string $format): static + { + $this->dateFormat = $format; + + return $this; + } + + /** + * Determine whether an attribute should be cast to a native type. + */ + public function hasCast(string $key, array|string|null $types = null): bool + { + if (array_key_exists($key, $this->getCasts())) { + return $types ? in_array($this->getCastType($key), (array) $types, true) : true; + } + + return false; + } + + /** + * Get the attributes that should be cast. + */ + public function getCasts(): array + { + if ($this->getIncrementing()) { + return array_merge([$this->getKeyName() => $this->getKeyType()], $this->casts); + } + + return $this->casts; + } + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return []; + } + + /** + * Determine whether a value is Date / DateTime castable for inbound manipulation. + */ + protected function isDateCastable(string $key): bool + { + return $this->hasCast($key, ['date', 'datetime', 'immutable_date', 'immutable_datetime']); + } + + /** + * Determine whether a value is Date / DateTime custom-castable for inbound manipulation. + */ + protected function isDateCastableWithCustomFormat(string $key): bool + { + return $this->hasCast($key, ['custom_datetime', 'immutable_custom_datetime']); + } + + /** + * Determine whether a value is JSON castable for inbound manipulation. + */ + protected function isJsonCastable(string $key): bool + { + return $this->hasCast($key, ['array', 'json', 'json:unicode', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + } + + /** + * Determine whether a value is an encrypted castable for inbound manipulation. + */ + protected function isEncryptedCastable(string $key): bool + { + return $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + } + + /** + * Determine if the given key is cast using a custom class. + * + * @throws \Hypervel\Database\Eloquent\InvalidCastException + */ + protected function isClassCastable(string $key): bool + { + $casts = $this->getCasts(); + + if (! array_key_exists($key, $casts)) { + return false; + } + + $castType = $this->parseCasterClass($casts[$key]); + + if (in_array($castType, static::$primitiveCastTypes)) { + return false; + } + + if (class_exists($castType)) { + return true; + } + + throw new InvalidCastException($this, $key, $castType); + } + + /** + * Determine if the given key is cast using an enum. + */ + protected function isEnumCastable(string $key): bool + { + $casts = $this->getCasts(); + + if (! array_key_exists($key, $casts)) { + return false; + } + + $castType = $casts[$key]; + + if (in_array($castType, static::$primitiveCastTypes)) { + return false; + } + + if (is_subclass_of($castType, Castable::class)) { + return false; + } + + return enum_exists($castType); + } + + /** + * Determine if the key is deviable using a custom class. + * + * @throws \Hypervel\Database\Eloquent\InvalidCastException + */ + protected function isClassDeviable(string $key): bool + { + if (! $this->isClassCastable($key)) { + return false; + } + + $castType = $this->resolveCasterClass($key); + + return method_exists($castType::class, 'increment') && method_exists($castType::class, 'decrement'); + } + + /** + * Determine if the key is serializable using a custom class. + * + * @throws \Hypervel\Database\Eloquent\InvalidCastException + */ + protected function isClassSerializable(string $key): bool + { + return ! $this->isEnumCastable($key) + && $this->isClassCastable($key) + && method_exists($this->resolveCasterClass($key), 'serialize'); + } + + /** + * Determine if the key is comparable using a custom class. + */ + protected function isClassComparable(string $key): bool + { + return ! $this->isEnumCastable($key) + && $this->isClassCastable($key) + && method_exists($this->resolveCasterClass($key), 'compare'); + } + + /** + * Resolve the custom caster class for a given key. + */ + protected function resolveCasterClass(string $key): mixed + { + $castType = $this->getCasts()[$key]; + + $arguments = []; + + if (is_string($castType) && str_contains($castType, ':')) { + $segments = explode(':', $castType, 2); + + $castType = $segments[0]; + $arguments = explode(',', $segments[1]); + } + + if (is_subclass_of($castType, Castable::class)) { + $castType = $castType::castUsing($arguments); + } + + if (is_object($castType)) { + return $castType; + } + + return new $castType(...$arguments); + } + + /** + * Parse the given caster class, removing any arguments. + */ + protected function parseCasterClass(string $class): string + { + return ! str_contains($class, ':') + ? $class + : explode(':', $class, 2)[0]; + } + + /** + * Merge the cast class and attribute cast attributes back into the model. + */ + protected function mergeAttributesFromCachedCasts(): void + { + $this->mergeAttributesFromClassCasts(); + $this->mergeAttributesFromAttributeCasts(); + } + + /** + * Merge the cast class attributes back into the model. + */ + protected function mergeAttributesFromClassCasts(): void + { + foreach ($this->classCastCache as $key => $value) { + $caster = $this->resolveCasterClass($key); + + $this->attributes = array_merge( + $this->attributes, + $caster instanceof CastsInboundAttributes + ? [$key => $value] + : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) + ); + } + } + + /** + * Merge the cast class attributes back into the model. + */ + protected function mergeAttributesFromAttributeCasts(): void + { + foreach ($this->attributeCastCache as $key => $value) { + $attribute = $this->{StrCache::camel($key)}(); + + if ($attribute->get && ! $attribute->set) { + continue; + } + + $callback = $attribute->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, + $callback($value, $this->attributes) + ) + ); + } + } + + /** + * Normalize the response from a custom class caster. + */ + protected function normalizeCastClassResponse(string $key, mixed $value): array + { + return is_array($value) ? $value : [$key => $value]; + } + + /** + * Get all of the current attributes on the model. + * + * @return array + */ + public function getAttributes(): array + { + $this->mergeAttributesFromCachedCasts(); + + return $this->attributes; + } + + /** + * Get all of the current attributes on the model for an insert operation. + */ + protected function getAttributesForInsert(): array + { + return $this->getAttributes(); + } + + /** + * Set the array of model attributes. No checking is done. + */ + public function setRawAttributes(array $attributes, bool $sync = false): static + { + $this->attributes = $attributes; + + if ($sync) { + $this->syncOriginal(); + } + + $this->classCastCache = []; + $this->attributeCastCache = []; + + return $this; + } + + /** + * Get the model's original attribute values. + * + * @return ($key is null ? array : mixed) + */ + public function getOriginal(?string $key = null, mixed $default = null): mixed + { + return (new static())->setRawAttributes( + $this->original, + $sync = true + )->getOriginalWithoutRewindingModel($key, $default); + } + + /** + * Get the model's original attribute values. + * + * @return ($key is null ? array : mixed) + */ + protected function getOriginalWithoutRewindingModel(?string $key = null, mixed $default = null): mixed + { + if ($key) { + return $this->transformModelValue( + $key, + Arr::get($this->original, $key, $default) + ); + } + + return (new Collection($this->original)) + ->mapWithKeys(fn ($value, $key) => [$key => $this->transformModelValue($key, $value)]) + ->all(); + } + + /** + * Get the model's raw original attribute values. + * + * @return ($key is null ? array : mixed) + */ + public function getRawOriginal(?string $key = null, mixed $default = null): mixed + { + return Arr::get($this->original, $key, $default); + } + + /** + * Get a subset of the model's attributes. + * + * @param array|mixed $attributes + * @return array + */ + public function only(mixed $attributes): array + { + $results = []; + + foreach (is_array($attributes) ? $attributes : func_get_args() as $attribute) { + $results[$attribute] = $this->getAttribute($attribute); + } + + return $results; + } + + /** + * Get all attributes except the given ones. + * + * @param array|mixed $attributes + */ + public function except(mixed $attributes): array + { + $attributes = is_array($attributes) ? $attributes : func_get_args(); + + $results = []; + + foreach ($this->getAttributes() as $key => $value) { + if (! in_array($key, $attributes)) { + $results[$key] = $this->getAttribute($key); + } + } + + return $results; + } + + /** + * Sync the original attributes with the current. + */ + public function syncOriginal(): static + { + $this->original = $this->getAttributes(); + + return $this; + } + + /** + * Sync a single original attribute with its current value. + */ + public function syncOriginalAttribute(string $attribute): static + { + return $this->syncOriginalAttributes($attribute); + } + + /** + * Sync multiple original attribute with their current values. + * + * @param array|string $attributes + */ + public function syncOriginalAttributes(array|string $attributes): static + { + $attributes = is_array($attributes) ? $attributes : func_get_args(); + + $modelAttributes = $this->getAttributes(); + + foreach ($attributes as $attribute) { + $this->original[$attribute] = $modelAttributes[$attribute]; + } + + return $this; + } + + /** + * Sync the changed attributes. + */ + public function syncChanges(): static + { + $this->changes = $this->getDirty(); + $this->previous = array_intersect_key($this->getRawOriginal(), $this->changes); + + return $this; + } + + /** + * Determine if the model or any of the given attribute(s) have been modified. + * + * @param null|array|string $attributes + */ + public function isDirty(array|string|null $attributes = null): bool + { + return $this->hasChanges( + $this->getDirty(), + is_array($attributes) ? $attributes : func_get_args() + ); + } + + /** + * Determine if the model or all the given attribute(s) have remained the same. + * + * @param null|array|string $attributes + */ + public function isClean(array|string|null $attributes = null): bool + { + return ! $this->isDirty(...func_get_args()); + } + + /** + * Discard attribute changes and reset the attributes to their original state. + */ + public function discardChanges(): static + { + [$this->attributes, $this->changes, $this->previous] = [$this->original, [], []]; + + $this->classCastCache = []; + $this->attributeCastCache = []; + + return $this; + } + + /** + * Determine if the model or any of the given attribute(s) were changed when the model was last saved. + * + * @param null|array|string $attributes + */ + public function wasChanged(array|string|null $attributes = null): bool + { + return $this->hasChanges( + $this->getChanges(), + is_array($attributes) ? $attributes : func_get_args() + ); + } + + /** + * Determine if any of the given attributes were changed when the model was last saved. + * + * @param array $changes + * @param null|array|string $attributes + */ + protected function hasChanges(array $changes, array|string|null $attributes = null): bool + { + // If no specific attributes were provided, we will just see if the dirty array + // already contains any attributes. If it does we will just return that this + // count is greater than zero. Else, we need to check specific attributes. + if (empty($attributes)) { + return count($changes) > 0; + } + + // Here we will spin through every attribute and see if this is in the array of + // dirty attributes. If it is, we will return true and if we make it through + // all of the attributes for the entire array we will return false at end. + foreach (Arr::wrap($attributes) as $attribute) { + if (array_key_exists($attribute, $changes)) { + return true; + } + } + + return false; + } + + /** + * Get the attributes that have been changed since the last sync. + * + * @return array + */ + public function getDirty(): array + { + $dirty = []; + + foreach ($this->getAttributes() as $key => $value) { + if (! $this->originalIsEquivalent($key)) { + $dirty[$key] = $value; + } + } + + return $dirty; + } + + /** + * Get the attributes that have been changed since the last sync for an update operation. + * + * @return array + */ + protected function getDirtyForUpdate(): array + { + return $this->getDirty(); + } + + /** + * Get the attributes that were changed when the model was last saved. + * + * @return array + */ + public function getChanges(): array + { + return $this->changes; + } + + /** + * Get the attributes that were previously original before the model was last saved. + * + * @return array + */ + public function getPrevious(): array + { + return $this->previous; + } + + /** + * Determine if the new and old values for a given key are equivalent. + */ + public function originalIsEquivalent(string $key): bool + { + if (! array_key_exists($key, $this->original)) { + return false; + } + + $attribute = Arr::get($this->attributes, $key); + $original = Arr::get($this->original, $key); + + if ($attribute === $original) { + return true; + } + if (is_null($attribute)) { + return false; + } + if ($this->isDateAttribute($key) || $this->isDateCastableWithCustomFormat($key)) { + return $this->fromDateTime($attribute) + === $this->fromDateTime($original); + } + if ($this->hasCast($key, ['object', 'collection'])) { + return $this->fromJson($attribute) + === $this->fromJson($original); + } + if ($this->hasCast($key, ['real', 'float', 'double'])) { + if ($original === null) { + return false; + } + + return abs($this->castAttribute($key, $attribute) - $this->castAttribute($key, $original)) < PHP_FLOAT_EPSILON * 4; + } + if ($this->isEncryptedCastable($key) && ! empty(static::currentEncrypter()->getPreviousKeys())) { + return false; + } + if ($this->hasCast($key, static::$primitiveCastTypes)) { + return $this->castAttribute($key, $attribute) + === $this->castAttribute($key, $original); + } + if ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsArrayObject::class, AsCollection::class])) { + return $this->fromJson($attribute) === $this->fromJson($original); + } + if ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsEnumArrayObject::class, AsEnumCollection::class])) { + return $this->fromJson($attribute) === $this->fromJson($original); + } + if ($this->isClassCastable($key) && $original !== null && Str::startsWith($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class])) { + if (empty(static::currentEncrypter()->getPreviousKeys())) { + return $this->fromEncryptedString($attribute) === $this->fromEncryptedString($original); + } + + return false; + } + if ($this->isClassComparable($key)) { + return $this->compareClassCastableAttribute($key, $original, $attribute); + } + + return is_numeric($attribute) && is_numeric($original) + && strcmp((string) $attribute, (string) $original) === 0; + } + + /** + * Transform a raw model value using mutators, casts, etc. + */ + protected function transformModelValue(string $key, mixed $value): mixed + { + // If the attribute has a get mutator, we will call that then return what + // it returns as the value, which is useful for transforming values on + // retrieval from the model to a form that is more useful for usage. + if ($this->hasGetMutator($key)) { + return $this->mutateAttribute($key, $value); + } + if ($this->hasAttributeGetMutator($key)) { + return $this->mutateAttributeMarkedAttribute($key, $value); + } + + // If the attribute exists within the cast array, we will convert it to + // an appropriate native PHP type dependent upon the associated value + // given with the key in the pair. Dayle made this comment line up. + if ($this->hasCast($key)) { + if (static::preventsAccessingMissingAttributes() + && ! array_key_exists($key, $this->attributes) + && ($this->isEnumCastable($key) + || in_array($this->getCastType($key), static::$primitiveCastTypes))) { + $this->throwMissingAttributeExceptionIfApplicable($key); + } + + return $this->castAttribute($key, $value); + } + + // If the attribute is listed as a date, we will convert it to a DateTime + // instance on retrieval, which makes it quite convenient to work with + // date fields without having to create a mutator for each property. + if ($value !== null + && \in_array($key, $this->getDates(), false)) { + return $this->asDateTime($value); + } + + return $value; + } + + /** + * Append attributes to query when building a query. + * + * @param array|string $attributes + */ + public function append(array|string $attributes): static + { + $this->appends = array_values(array_unique( + array_merge($this->appends, is_string($attributes) ? func_get_args() : $attributes) + )); + + return $this; + } + + /** + * Get the accessors that are being appended to model arrays. + */ + public function getAppends(): array + { + return $this->appends; + } + + /** + * Set the accessors to append to model arrays. + */ + public function setAppends(array $appends): static + { + $this->appends = $appends; + + return $this; + } + + /** + * Merge new appended attributes with existing appended attributes on the model. + * + * @param array $appends + */ + public function mergeAppends(array $appends): static + { + $this->appends = array_values(array_unique(array_merge($this->appends, $appends))); + + return $this; + } + + /** + * Return whether the accessor attribute has been appended. + */ + public function hasAppended(string $attribute): bool + { + return in_array($attribute, $this->appends); + } + + /** + * Get the mutated attributes for a given instance. + */ + public function getMutatedAttributes(): array + { + if (! isset(static::$mutatorCache[static::class])) { + static::cacheMutatedAttributes($this); + } + + return static::$mutatorCache[static::class]; + } + + /** + * Extract and cache all the mutated attributes of a class. + */ + public static function cacheMutatedAttributes(object|string $classOrInstance): void + { + $reflection = new ReflectionClass($classOrInstance); + + $class = $reflection->getName(); + + static::$getAttributeMutatorCache[$class] = (new Collection($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($classOrInstance))) + ->mapWithKeys(fn ($match) => [lcfirst(static::$snakeAttributes ? StrCache::snake($match) : $match) => true]) + ->all(); + + static::$mutatorCache[$class] = (new Collection(static::getMutatorMethods($class))) + ->merge($attributeMutatorMethods) + ->map(fn ($match) => lcfirst(static::$snakeAttributes ? StrCache::snake($match) : $match)) + ->all(); + } + + /** + * Get all of the attribute mutator methods. + */ + protected static function getMutatorMethods(mixed $class): array + { + preg_match_all('/(?<=^|;)get([^;]+?)Attribute(;|$)/', implode(';', get_class_methods($class)), $matches); + + return $matches[1]; + } + + /** + * Get all of the "Attribute" return typed attribute mutator methods. + */ + protected static function getAttributeMarkedMutatorMethods(mixed $class): array + { + $instance = is_object($class) ? $class : new $class(); + + // @phpstan-ignore method.nonObject (HigherOrderProxy: ->map->name returns Collection, not string) + return (new Collection((new ReflectionClass($instance))->getMethods()))->filter(function ($method) use ($instance) { + $returnType = $method->getReturnType(); + + if ($returnType instanceof ReflectionNamedType + && $returnType->getName() === Attribute::class) { + if (is_callable($method->invoke($instance)->get)) { + return true; + } + } + + return false; + })->map->name->values()->all(); + } +} diff --git a/src/database/src/Eloquent/Concerns/HasEvents.php b/src/database/src/Eloquent/Concerns/HasEvents.php new file mode 100644 index 000000000..24e2b08b9 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasEvents.php @@ -0,0 +1,450 @@ + + */ + protected array $dispatchesEvents = []; + + /** + * User exposed observable events. + * + * These are extra user-defined events observers may subscribe to. + * + * @var string[] + */ + protected array $observables = []; + + /** + * Boot the has event trait for a model. + */ + public static function bootHasEvents(): void + { + static::whenBooted(function () { + $observers = static::resolveObserveAttributes(); + + if (! empty($observers)) { + static::observe($observers); + } + }); + } + + /** + * Resolve the observe class names from the attributes. + * + * @return array + */ + 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) + // @phpstan-ignore staticMethod.nonObject ($isEloquentGrandchild guarantees parent exists and is Model subclass) + return (new Collection(get_parent_class(static::class)::resolveObserveAttributes())) + ->merge($attributes); + }) + ->all(); + } + + /** + * Register observers with the model. + * + * @param array|class-string|object $classes + * + * @throws InvalidArgumentException + */ + public static function observe(object|array|string $classes): void + { + $instance = new static(); + + foreach (Arr::wrap($classes) as $class) { + $instance->registerObserver($class); + } + } + + /** + * Register a single observer with the model. + * + * @param class-string|object $class + * + * @throws InvalidArgumentException + */ + protected function registerObserver(object|string $class): void + { + $className = $this->resolveObserverClassName($class); + + // When registering a model observer, we will spin through the possible events + // and determine if this observer has that method. If it does, we will hook + // it into the model's event system, making it convenient to watch these. + foreach ($this->getObservableEvents() as $event) { + if (method_exists($class, $event)) { + static::registerModelEvent($event, $className . '@' . $event); + } + } + } + + /** + * Resolve the observer's class name from an object or string. + * + * @param class-string|object $class + * @return class-string + * + * @throws InvalidArgumentException + */ + private function resolveObserverClassName(object|string $class): string + { + if (is_object($class)) { + return $class::class; + } + + if (class_exists($class)) { + return $class; + } + + throw new InvalidArgumentException('Unable to find observer: ' . $class); + } + + /** + * Get the observable event names. + * + * @return string[] + */ + public function getObservableEvents(): array + { + return array_merge( + [ + 'retrieved', 'creating', 'created', 'updating', 'updated', + 'saving', 'saved', 'restoring', 'restored', 'replicating', + 'trashed', 'deleting', 'deleted', 'forceDeleting', 'forceDeleted', + ], + $this->observables + ); + } + + /** + * Set the observable event names. + * + * @param string[] $observables + * @return $this + */ + public function setObservableEvents(array $observables): static + { + $this->observables = $observables; + + return $this; + } + + /** + * Add an observable event name. + * + * @param string|string[] $observables + */ + public function addObservableEvents(array|string $observables): void + { + $this->observables = array_unique(array_merge( + $this->observables, + is_array($observables) ? $observables : func_get_args() + )); + } + + /** + * Remove an observable event name. + * + * @param string|string[] $observables + */ + public function removeObservableEvents(array|string $observables): void + { + $this->observables = array_diff( + $this->observables, + is_array($observables) ? $observables : func_get_args() + ); + } + + /** + * Register a model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + protected static function registerModelEvent(string $event, mixed $callback): void + { + if (isset(static::$dispatcher)) { + $name = static::class; + + static::$dispatcher->listen("eloquent.{$event}: {$name}", $callback); + } + } + + /** + * Fire the given event for the model. + */ + protected function fireModelEvent(string $event, bool $halt = true): mixed + { + if (! isset(static::$dispatcher) || static::eventsDisabled()) { + return true; + } + + // First, we will get the proper method to call on the event dispatcher, and then we + // will attempt to fire a custom, object based event for the given event. If that + // returns a result we can return that result, or we'll call the string events. + $method = $halt ? 'until' : 'dispatch'; + + $result = $this->filterModelEventResults( + $this->fireCustomModelEvent($event, $method) + ); + + if ($result === false) { + return false; + } + + return ! empty($result) ? $result : static::$dispatcher->{$method}( + "eloquent.{$event}: " . static::class, + $this + ); + } + + /** + * Fire a custom model event for the given event. + * + * @param 'dispatch'|'until' $method + */ + protected function fireCustomModelEvent(string $event, string $method): mixed + { + if (! isset($this->dispatchesEvents[$event])) { + return null; + } + + $result = static::$dispatcher->{$method}(new $this->dispatchesEvents[$event]($this)); + + if (! is_null($result)) { + return $result; + } + + return null; + } + + /** + * Filter the model event results. + */ + protected function filterModelEventResults(mixed $result): mixed + { + if (is_array($result)) { + $result = array_filter($result, fn ($response) => ! is_null($response)); + } + + return $result; + } + + /** + * Register a retrieved model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function retrieved(mixed $callback): void + { + static::registerModelEvent('retrieved', $callback); + } + + /** + * Register a saving model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function saving(mixed $callback): void + { + static::registerModelEvent('saving', $callback); + } + + /** + * Register a saved model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function saved(mixed $callback): void + { + static::registerModelEvent('saved', $callback); + } + + /** + * Register an updating model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function updating(mixed $callback): void + { + static::registerModelEvent('updating', $callback); + } + + /** + * Register an updated model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function updated(mixed $callback): void + { + static::registerModelEvent('updated', $callback); + } + + /** + * Register a creating model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function creating(mixed $callback): void + { + static::registerModelEvent('creating', $callback); + } + + /** + * Register a created model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function created(mixed $callback): void + { + static::registerModelEvent('created', $callback); + } + + /** + * Register a replicating model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function replicating(mixed $callback): void + { + static::registerModelEvent('replicating', $callback); + } + + /** + * Register a deleting model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function deleting(mixed $callback): void + { + static::registerModelEvent('deleting', $callback); + } + + /** + * Register a deleted model event with the dispatcher. + * + * @param array|callable|class-string|\Hypervel\Event\QueuedClosure $callback + */ + public static function deleted(mixed $callback): void + { + static::registerModelEvent('deleted', $callback); + } + + /** + * Remove all the event listeners for the model. + */ + public static function flushEventListeners(): void + { + if (! isset(static::$dispatcher)) { + return; + } + + $instance = new static(); + + foreach ($instance->getObservableEvents() as $event) { + static::$dispatcher->forget("eloquent.{$event}: " . static::class); + } + + foreach ($instance->dispatchesEvents as $event) { + static::$dispatcher->forget($event); + } + } + + /** + * Get the event map for the model. + * + * @return array + */ + public function dispatchesEvents(): array + { + return $this->dispatchesEvents; + } + + /** + * Get the event dispatcher instance. + * + * Returns a NullDispatcher when events are disabled (inside withoutEvents()) + * to ensure manual dispatch() calls are also suppressed, matching Laravel behavior. + */ + public static function getEventDispatcher(): ?Dispatcher + { + if (static::eventsDisabled() && static::$dispatcher !== null) { + return new NullDispatcher(static::$dispatcher); + } + + return static::$dispatcher; + } + + /** + * Set the event dispatcher instance. + */ + public static function setEventDispatcher(Dispatcher $dispatcher): void + { + static::$dispatcher = $dispatcher; + } + + /** + * Unset the event dispatcher for models. + */ + public static function unsetEventDispatcher(): void + { + static::$dispatcher = null; + } + + /** + * Execute a callback without firing any model events for any model type. + * + * Uses Context for coroutine-safe event disabling, ensuring concurrent + * requests don't interfere with each other's event handling. + */ + public static function withoutEvents(callable $callback): mixed + { + $wasDisabled = Context::get(self::EVENTS_DISABLED_CONTEXT_KEY, false); + Context::set(self::EVENTS_DISABLED_CONTEXT_KEY, true); + + try { + return $callback(); + } finally { + Context::set(self::EVENTS_DISABLED_CONTEXT_KEY, $wasDisabled); + } + } + + /** + * Determine if model events are currently disabled for this coroutine. + */ + public static function eventsDisabled(): bool + { + return (bool) Context::get(self::EVENTS_DISABLED_CONTEXT_KEY, false); + } +} diff --git a/src/database/src/Eloquent/Concerns/HasGlobalScopes.php b/src/database/src/Eloquent/Concerns/HasGlobalScopes.php new file mode 100644 index 000000000..83f698d81 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasGlobalScopes.php @@ -0,0 +1,132 @@ +getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF))); + + foreach ($reflectionClass->getTraits() as $trait) { + $attributes->push(...$trait->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF)); + } + + return $attributes->map(fn ($attribute) => $attribute->getArguments()) + ->flatten() + ->all(); + } + + /** + * Register a new global scope on the model. + * + * @param (Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Eloquent\Scope|string $scope + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Eloquent\Scope $implementation + * + * @throws InvalidArgumentException + */ + public static function addGlobalScope(Scope|Closure|string $scope, Scope|Closure|null $implementation = null): mixed + { + if (is_string($scope) && ($implementation instanceof Closure || $implementation instanceof Scope)) { + return static::$globalScopes[static::class][$scope] = $implementation; + } + if ($scope instanceof Closure) { + return static::$globalScopes[static::class][spl_object_hash($scope)] = $scope; + } + if ($scope instanceof Scope) { + return static::$globalScopes[static::class][get_class($scope)] = $scope; + } + if (class_exists($scope) && is_subclass_of($scope, Scope::class)) { + return static::$globalScopes[static::class][$scope] = new $scope(); + } + + throw new InvalidArgumentException('Global scope must be an instance of Closure or Scope or be a class name of a class extending ' . Scope::class); + } + + /** + * Register multiple global scopes on the model. + */ + public static function addGlobalScopes(array $scopes): void + { + foreach ($scopes as $key => $scope) { + if (is_string($key)) { + static::addGlobalScope($key, $scope); + } else { + static::addGlobalScope($scope); + } + } + } + + /** + * Determine if a model has a global scope. + */ + public static function hasGlobalScope(Scope|string $scope): bool + { + return ! is_null(static::getGlobalScope($scope)); + } + + /** + * Get a global scope registered with the model. + * + * @return null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Eloquent\Scope + */ + public static function getGlobalScope(Scope|string $scope): Scope|Closure|null + { + if (is_string($scope)) { + return Arr::get(static::$globalScopes, static::class . '.' . $scope); + } + + return Arr::get( + static::$globalScopes, + static::class . '.' . get_class($scope) + ); + } + + /** + * Get all of the global scopes that are currently registered. + */ + public static function getAllGlobalScopes(): array + { + return static::$globalScopes; + } + + /** + * Set the current global scopes. + */ + public static function setAllGlobalScopes(array $scopes): void + { + static::$globalScopes = $scopes; + } + + /** + * Get the global scopes for this class instance. + */ + public function getGlobalScopes(): array + { + return Arr::get(static::$globalScopes, static::class, []); + } +} diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php new file mode 100644 index 000000000..86ab9a2f4 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -0,0 +1,1046 @@ + $class + */ + public function relationResolver(string $class, string $key): ?Closure + { + if ($resolver = static::$relationResolvers[$class][$key] ?? null) { + return $resolver; + } + + if ($parent = get_parent_class($class)) { + return $this->relationResolver($parent, $key); + } + + return null; + } + + /** + * Define a dynamic relation resolver. + */ + public static function resolveRelationUsing(string $name, Closure $callback): void + { + static::$relationResolvers = array_replace_recursive( + static::$relationResolvers, + [static::class => [$name => $callback]] + ); + } + + /** + * Determine if a relationship autoloader callback has been defined. + */ + public function hasRelationAutoloadCallback(): bool + { + return ! is_null($this->relationAutoloadCallback); + } + + /** + * Define an automatic relationship autoloader callback for this model and its relations. + */ + public function autoloadRelationsUsing(Closure $callback, mixed $context = null): static + { + // Prevent circular relation autoloading... + if ($context && $this->relationAutoloadContext === $context) { + return $this; + } + + $this->relationAutoloadCallback = $callback; + $this->relationAutoloadContext = $context; + + foreach ($this->relations as $key => $value) { + $this->propagateRelationAutoloadCallbackToRelation($key, $value); + } + + return $this; + } + + /** + * Attempt to autoload the given relationship using the autoload callback. + */ + protected function attemptToAutoloadRelation(string $key): bool + { + if (! $this->hasRelationAutoloadCallback()) { + return false; + } + + $this->invokeRelationAutoloadCallbackFor($key, []); + + return $this->relationLoaded($key); + } + + /** + * Invoke the relationship autoloader callback for the given relationships. + */ + protected function invokeRelationAutoloadCallbackFor(string $key, array $tuples): void + { + $tuples = array_merge([[$key, get_class($this)]], $tuples); + + call_user_func($this->relationAutoloadCallback, $tuples); + } + + /** + * Propagate the relationship autoloader callback to the given related models. + */ + protected function propagateRelationAutoloadCallbackToRelation(string $key, mixed $models): void + { + if (! $this->hasRelationAutoloadCallback() || ! $models) { + return; + } + + if ($models instanceof Model) { + $models = [$models]; + } + + if (! is_iterable($models)) { + return; + } + + $callback = fn (array $tuples) => $this->invokeRelationAutoloadCallbackFor($key, $tuples); + + foreach ($models as $model) { + $model->autoloadRelationsUsing($callback, $this->relationAutoloadContext); + } + } + + /** + * Define a one-to-one relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @return \Hypervel\Database\Eloquent\Relations\HasOne + */ + public function hasOne(string $related, ?string $foreignKey = null, ?string $localKey = null): HasOne + { + $instance = $this->newRelatedInstance($related); + + $foreignKey = $foreignKey ?: $this->getForeignKey(); + + $localKey = $localKey ?: $this->getKeyName(); + + return $this->newHasOne($instance->newQuery(), $this, $instance->qualifyColumn($foreignKey), $localKey); + } + + /** + * Instantiate a new HasOne relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @return \Hypervel\Database\Eloquent\Relations\HasOne + */ + protected function newHasOne(Builder $query, Model $parent, string $foreignKey, string $localKey): HasOne + { + return new HasOne($query, $parent, $foreignKey, $localKey); + } + + /** + * Define a has-one-through relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TIntermediateModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @param class-string $through + * @return \Hypervel\Database\Eloquent\Relations\HasOneThrough + */ + public function hasOneThrough(string $related, string $through, ?string $firstKey = null, ?string $secondKey = null, ?string $localKey = null, ?string $secondLocalKey = null): HasOneThrough + { + $through = $this->newRelatedThroughInstance($through); + + $firstKey = $firstKey ?: $this->getForeignKey(); + + $secondKey = $secondKey ?: $through->getForeignKey(); + + return $this->newHasOneThrough( + $this->newRelatedInstance($related)->newQuery(), + $this, + $through, + $firstKey, + $secondKey, + $localKey ?: $this->getKeyName(), + $secondLocalKey ?: $through->getKeyName(), + ); + } + + /** + * Instantiate a new HasOneThrough relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TIntermediateModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $farParent + * @param TIntermediateModel $throughParent + * @return \Hypervel\Database\Eloquent\Relations\HasOneThrough + */ + protected function newHasOneThrough(Builder $query, Model $farParent, Model $throughParent, string $firstKey, string $secondKey, string $localKey, string $secondLocalKey): HasOneThrough + { + return new HasOneThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + } + + /** + * Define a polymorphic one-to-one relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @return \Hypervel\Database\Eloquent\Relations\MorphOne + */ + public function morphOne(string $related, string $name, ?string $type = null, ?string $id = null, ?string $localKey = null): MorphOne + { + $instance = $this->newRelatedInstance($related); + + [$type, $id] = $this->getMorphs($name, $type, $id); + + $localKey = $localKey ?: $this->getKeyName(); + + return $this->newMorphOne($instance->newQuery(), $this, $instance->qualifyColumn($type), $instance->qualifyColumn($id), $localKey); + } + + /** + * Instantiate a new MorphOne relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @return \Hypervel\Database\Eloquent\Relations\MorphOne + */ + protected function newMorphOne(Builder $query, Model $parent, string $type, string $id, string $localKey): MorphOne + { + return new MorphOne($query, $parent, $type, $id, $localKey); + } + + /** + * Define an inverse one-to-one or many relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @return \Hypervel\Database\Eloquent\Relations\BelongsTo + */ + public function belongsTo(string $related, ?string $foreignKey = null, ?string $ownerKey = null, ?string $relation = null): BelongsTo + { + // If no relation name was given, we will use this debug backtrace to extract + // the calling method's name and use that as the relationship name as most + // of the time this will be what we desire to use for the relationships. + if (is_null($relation)) { + $relation = $this->guessBelongsToRelation(); + } + + $instance = $this->newRelatedInstance($related); + + // If no foreign key was supplied, we can use a backtrace to guess the proper + // foreign key name by using the name of the relationship function, which + // when combined with an "_id" should conventionally match the columns. + if (is_null($foreignKey)) { + $foreignKey = StrCache::snake($relation) . '_' . $instance->getKeyName(); + } + + // Once we have the foreign key names we'll just create a new Eloquent query + // for the related models and return the relationship instance which will + // actually be responsible for retrieving and hydrating every relation. + $ownerKey = $ownerKey ?: $instance->getKeyName(); + + return $this->newBelongsTo( + $instance->newQuery(), + $this, + $foreignKey, + $ownerKey, + $relation + ); + } + + /** + * Instantiate a new BelongsTo relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $child + * @return \Hypervel\Database\Eloquent\Relations\BelongsTo + */ + protected function newBelongsTo(Builder $query, Model $child, string $foreignKey, string $ownerKey, string $relation): BelongsTo + { + return new BelongsTo($query, $child, $foreignKey, $ownerKey, $relation); + } + + /** + * Define a polymorphic, inverse one-to-one or many relationship. + * + * @return \Hypervel\Database\Eloquent\Relations\MorphTo<\Hypervel\Database\Eloquent\Model, $this> + */ + public function morphTo(?string $name = null, ?string $type = null, ?string $id = null, ?string $ownerKey = null): MorphTo + { + // If no name is provided, we will use the backtrace to get the function name + // since that is most likely the name of the polymorphic interface. We can + // use that to get both the class and foreign key that will be utilized. + $name = $name ?: $this->guessBelongsToRelation(); + + [$type, $id] = $this->getMorphs( + StrCache::snake($name), + $type, + $id + ); + + // If the type value is null it is probably safe to assume we're eager loading + // the relationship. In this case we'll just pass in a dummy query where we + // need to remove any eager loads that may already be defined on a model. + return is_null($class = $this->getAttributeFromArray($type)) || $class === '' + ? $this->morphEagerTo($name, $type, $id, $ownerKey) + : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey); + } + + /** + * Define a polymorphic, inverse one-to-one or many relationship. + * + * @return \Hypervel\Database\Eloquent\Relations\MorphTo<\Hypervel\Database\Eloquent\Model, $this> + */ + protected function morphEagerTo(string $name, string $type, string $id, ?string $ownerKey): MorphTo + { + // @phpstan-ignore return.type (MorphTo vs MorphTo - template covariance) + return $this->newMorphTo( + $this->newQuery()->setEagerLoads([]), + $this, + $id, + $ownerKey, + $type, + $name + ); + } + + /** + * Define a polymorphic, inverse one-to-one or many relationship. + * + * @return \Hypervel\Database\Eloquent\Relations\MorphTo<\Hypervel\Database\Eloquent\Model, $this> + */ + protected function morphInstanceTo(string $target, string $name, string $type, string $id, ?string $ownerKey): MorphTo + { + $instance = $this->newRelatedInstance( + static::getActualClassNameForMorph($target) + ); + + return $this->newMorphTo( + $instance->newQuery(), + $this, + $id, + $ownerKey ?? $instance->getKeyName(), + $type, + $name + ); + } + + /** + * Instantiate a new MorphTo relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @return \Hypervel\Database\Eloquent\Relations\MorphTo + */ + protected function newMorphTo(Builder $query, Model $parent, string $foreignKey, ?string $ownerKey, string $type, string $relation): MorphTo + { + return new MorphTo($query, $parent, $foreignKey, $ownerKey, $type, $relation); + } + + /** + * Retrieve the actual class name for a given morph class. + */ + public static function getActualClassNameForMorph(string $class): string + { + return Arr::get(Relation::morphMap() ?: [], $class, $class); + } + + /** + * Guess the "belongs to" relationship name. + */ + protected function guessBelongsToRelation(): string + { + [, , $caller] = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + + return $caller['function']; + } + + /** + * Create a pending has-many-through or has-one-through relationship. + * + * @template TIntermediateModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\HasMany|\Hypervel\Database\Eloquent\Relations\HasOne|string $relationship + * @return ( + * $relationship is string + * ? \Hypervel\Database\Eloquent\PendingHasThroughRelationship<\Hypervel\Database\Eloquent\Model, $this> + * : ( + * $relationship is \Hypervel\Database\Eloquent\Relations\HasMany + * ? \Hypervel\Database\Eloquent\PendingHasThroughRelationship> + * : \Hypervel\Database\Eloquent\PendingHasThroughRelationship> + * ) + * ) + * @phpstan-ignore conditionalType.alwaysFalse (template covariance limitation with conditional return types) + */ + public function through(string|HasMany|HasOne $relationship): PendingHasThroughRelationship + { + if (is_string($relationship)) { + $relationship = $this->{$relationship}(); + } + + // @phpstan-ignore return.type (template covariance with $this vs static in PendingHasThroughRelationship) + return new PendingHasThroughRelationship($this, $relationship); + } + + /** + * Define a one-to-many relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @return \Hypervel\Database\Eloquent\Relations\HasMany + */ + public function hasMany(string $related, ?string $foreignKey = null, ?string $localKey = null): HasMany + { + $instance = $this->newRelatedInstance($related); + + $foreignKey = $foreignKey ?: $this->getForeignKey(); + + $localKey = $localKey ?: $this->getKeyName(); + + return $this->newHasMany( + $instance->newQuery(), + $this, + $instance->qualifyColumn($foreignKey), + $localKey + ); + } + + /** + * Instantiate a new HasMany relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @return \Hypervel\Database\Eloquent\Relations\HasMany + */ + protected function newHasMany(Builder $query, Model $parent, string $foreignKey, string $localKey): HasMany + { + return new HasMany($query, $parent, $foreignKey, $localKey); + } + + /** + * Define a has-many-through relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TIntermediateModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @param class-string $through + * @return \Hypervel\Database\Eloquent\Relations\HasManyThrough + */ + public function hasManyThrough(string $related, string $through, ?string $firstKey = null, ?string $secondKey = null, ?string $localKey = null, ?string $secondLocalKey = null): HasManyThrough + { + $through = $this->newRelatedThroughInstance($through); + + $firstKey = $firstKey ?: $this->getForeignKey(); + + $secondKey = $secondKey ?: $through->getForeignKey(); + + return $this->newHasManyThrough( + $this->newRelatedInstance($related)->newQuery(), + $this, + $through, + $firstKey, + $secondKey, + $localKey ?: $this->getKeyName(), + $secondLocalKey ?: $through->getKeyName() + ); + } + + /** + * Instantiate a new HasManyThrough relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TIntermediateModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $farParent + * @param TIntermediateModel $throughParent + * @return \Hypervel\Database\Eloquent\Relations\HasManyThrough + */ + protected function newHasManyThrough(Builder $query, Model $farParent, Model $throughParent, string $firstKey, string $secondKey, string $localKey, string $secondLocalKey): HasManyThrough + { + return new HasManyThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + } + + /** + * Define a polymorphic one-to-many relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @return \Hypervel\Database\Eloquent\Relations\MorphMany + */ + public function morphMany(string $related, string $name, ?string $type = null, ?string $id = null, ?string $localKey = null): MorphMany + { + $instance = $this->newRelatedInstance($related); + + // Here we will gather up the morph type and ID for the relationship so that we + // can properly query the intermediate table of a relation. Finally, we will + // get the table and create the relationship instances for the developers. + [$type, $id] = $this->getMorphs($name, $type, $id); + + $localKey = $localKey ?: $this->getKeyName(); + + return $this->newMorphMany($instance->newQuery(), $this, $instance->qualifyColumn($type), $instance->qualifyColumn($id), $localKey); + } + + /** + * Instantiate a new MorphMany relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @return \Hypervel\Database\Eloquent\Relations\MorphMany + */ + protected function newMorphMany(Builder $query, Model $parent, string $type, string $id, string $localKey): MorphMany + { + return new MorphMany($query, $parent, $type, $id, $localKey); + } + + /** + * Define a many-to-many relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @param null|class-string<\Hypervel\Database\Eloquent\Model>|string $table + * @return \Hypervel\Database\Eloquent\Relations\BelongsToMany + */ + public function belongsToMany( + string $related, + ?string $table = null, + ?string $foreignPivotKey = null, + ?string $relatedPivotKey = null, + ?string $parentKey = null, + ?string $relatedKey = null, + ?string $relation = null, + ): BelongsToMany { + // If no relationship name was passed, we will pull backtraces to get the + // name of the calling function. We will use that function name as the + // title of this relation since that is a great convention to apply. + if (is_null($relation)) { + $relation = $this->guessBelongsToManyRelation(); + } + + // First, we'll need to determine the foreign key and "other key" for the + // relationship. Once we have determined the keys we'll make the query + // instances as well as the relationship instances we need for this. + $instance = $this->newRelatedInstance($related); + + $foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey(); + + $relatedPivotKey = $relatedPivotKey ?: $instance->getForeignKey(); + + // If no table name was provided, we can guess it by concatenating the two + // models using underscores in alphabetical order. The two model names + // are transformed to snake case from their default CamelCase also. + if (is_null($table)) { + $table = $this->joiningTable($related, $instance); + } + + return $this->newBelongsToMany( + $instance->newQuery(), + $this, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey ?: $this->getKeyName(), + $relatedKey ?: $instance->getKeyName(), + $relation, + ); + } + + /** + * Instantiate a new BelongsToMany relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @param class-string<\Hypervel\Database\Eloquent\Model>|string $table + * @return \Hypervel\Database\Eloquent\Relations\BelongsToMany + */ + protected function newBelongsToMany( + Builder $query, + Model $parent, + string $table, + string $foreignPivotKey, + string $relatedPivotKey, + string $parentKey, + string $relatedKey, + ?string $relationName = null, + ): BelongsToMany { + return new BelongsToMany($query, $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName); + } + + /** + * Define a polymorphic many-to-many relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @return \Hypervel\Database\Eloquent\Relations\MorphToMany + */ + public function morphToMany( + string $related, + string $name, + ?string $table = null, + ?string $foreignPivotKey = null, + ?string $relatedPivotKey = null, + ?string $parentKey = null, + ?string $relatedKey = null, + ?string $relation = null, + bool $inverse = false, + ): MorphToMany { + $relation = $relation ?: $this->guessBelongsToManyRelation(); + + // First, we will need to determine the foreign key and "other key" for the + // relationship. Once we have determined the keys we will make the query + // instances, as well as the relationship instances we need for these. + $instance = $this->newRelatedInstance($related); + + $foreignPivotKey = $foreignPivotKey ?: $name . '_id'; + + $relatedPivotKey = $relatedPivotKey ?: $instance->getForeignKey(); + + // Now we're ready to create a new query builder for the related model and + // the relationship instances for this relation. This relation will set + // appropriate query constraints then entirely manage the hydrations. + if (! $table) { + $words = preg_split('/(_)/u', $name, -1, PREG_SPLIT_DELIM_CAPTURE); + + $lastWord = array_pop($words); + + $table = implode('', $words) . StrCache::plural($lastWord); + } + + return $this->newMorphToMany( + $instance->newQuery(), + $this, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey ?: $this->getKeyName(), + $relatedKey ?: $instance->getKeyName(), + $relation, + $inverse, + ); + } + + /** + * Instantiate a new MorphToMany relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @return \Hypervel\Database\Eloquent\Relations\MorphToMany + */ + protected function newMorphToMany( + Builder $query, + Model $parent, + string $name, + string $table, + string $foreignPivotKey, + string $relatedPivotKey, + string $parentKey, + string $relatedKey, + ?string $relationName = null, + bool $inverse = false, + ): MorphToMany { + return new MorphToMany( + $query, + $parent, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName, + $inverse, + ); + } + + /** + * Define a polymorphic, inverse many-to-many relationship. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $related + * @return \Hypervel\Database\Eloquent\Relations\MorphToMany + */ + public function morphedByMany( + string $related, + string $name, + ?string $table = null, + ?string $foreignPivotKey = null, + ?string $relatedPivotKey = null, + ?string $parentKey = null, + ?string $relatedKey = null, + ?string $relation = null, + ): MorphToMany { + $foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey(); + + // For the inverse of the polymorphic many-to-many relations, we will change + // the way we determine the foreign and other keys, as it is the opposite + // of the morph-to-many method since we're figuring out these inverses. + $relatedPivotKey = $relatedPivotKey ?: $name . '_id'; + + return $this->morphToMany( + $related, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relation, + true, + ); + } + + /** + * Get the relationship name of the belongsToMany relationship. + */ + protected function guessBelongsToManyRelation(): ?string + { + $caller = Arr::first(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS), function ($trace) { + return ! in_array( + $trace['function'], + array_merge(static::$manyMethods, ['guessBelongsToManyRelation']) + ); + }); + + return ! is_null($caller) ? $caller['function'] : null; + } + + /** + * Get the joining table name for a many-to-many relation. + */ + public function joiningTable(string $related, ?Model $instance = null): string + { + // The joining table name, by convention, is simply the snake cased models + // sorted alphabetically and concatenated with an underscore, so we can + // just sort the models and join them together to get the table name. + $segments = [ + $instance + ? $instance->joiningTableSegment() + : StrCache::snake(class_basename($related)), + $this->joiningTableSegment(), + ]; + + // Now that we have the model names in an array we can just sort them and + // use the implode function to join them together with an underscores, + // which is typically used by convention within the database system. + sort($segments); + + return strtolower(implode('_', $segments)); + } + + /** + * Get this model's half of the intermediate table name for belongsToMany relationships. + */ + public function joiningTableSegment(): string + { + return StrCache::snake(class_basename($this)); + } + + /** + * Determine if the model touches a given relation. + */ + public function touches(string $relation): bool + { + return in_array($relation, $this->getTouchedRelations()); + } + + /** + * Touch the owning relations of the model. + */ + public function touchOwners(): void + { + $this->withoutRecursion(function () { + foreach ($this->getTouchedRelations() as $relation) { + $this->{$relation}()->touch(); + + if ($this->{$relation} instanceof self) { + $this->{$relation}->fireModelEvent('saved', false); + + $this->{$relation}->touchOwners(); + } elseif ($this->{$relation} instanceof EloquentCollection) { + $this->{$relation}->each->touchOwners(); + } + } + }); + } + + /** + * Get the polymorphic relationship columns. + * + * @return array{0: string, 1: string} + */ + protected function getMorphs(string $name, ?string $type, ?string $id): array + { + return [$type ?: $name . '_type', $id ?: $name . '_id']; + } + + /** + * Get the class name for polymorphic relations. + */ + public function getMorphClass(): string + { + $morphMap = Relation::morphMap(); + + if (! empty($morphMap) && in_array(static::class, $morphMap)) { + return array_search(static::class, $morphMap, true); + } + + if (static::class === Pivot::class) { + return static::class; + } + + if (Relation::requiresMorphMap()) { + throw new ClassMorphViolationException($this); + } + + return static::class; + } + + /** + * Create a new model instance for a related model. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $class + * @return TRelatedModel + */ + protected function newRelatedInstance(string $class): Model + { + return tap(new $class(), function ($instance) { + if (! $instance->getConnectionName()) { + $instance->setConnection($this->connection); + } + }); + } + + /** + * Create a new model instance for a related "through" model. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param class-string $class + * @return TRelatedModel + */ + protected function newRelatedThroughInstance(string $class): Model + { + return new $class(); + } + + /** + * Get all the loaded relations for the instance. + */ + public function getRelations(): array + { + return $this->relations; + } + + /** + * Get a specified relationship. + */ + public function getRelation(string $relation): mixed + { + return $this->relations[$relation]; + } + + /** + * Determine if the given relation is loaded. + */ + public function relationLoaded(string $key): bool + { + return array_key_exists($key, $this->relations); + } + + /** + * Set the given relationship on the model. + * + * @return $this + */ + public function setRelation(string $relation, mixed $value): static + { + $this->relations[$relation] = $value; + + $this->propagateRelationAutoloadCallbackToRelation($relation, $value); + + return $this; + } + + /** + * Unset a loaded relationship. + * + * @return $this + */ + public function unsetRelation(string $relation): static + { + unset($this->relations[$relation]); + + return $this; + } + + /** + * Set the entire relations array on the model. + * + * @return $this + */ + public function setRelations(array $relations): static + { + $this->relations = $relations; + + return $this; + } + + /** + * Enable relationship autoloading for this model. + * + * @return $this + */ + public function withRelationshipAutoloading(): static + { + $this->newCollection([$this])->withRelationshipAutoloading(); + + return $this; + } + + /** + * Duplicate the instance and unset all the loaded relations. + * + * @return $this + */ + public function withoutRelations(): static + { + $model = clone $this; + + return $model->unsetRelations(); + } + + /** + * Unset all the loaded relations for the instance. + * + * @return $this + */ + public function unsetRelations(): static + { + $this->relations = []; + + return $this; + } + + /** + * Get the relationships that are touched on save. + */ + public function getTouchedRelations(): array + { + return $this->touches; + } + + /** + * Set the relationships that are touched on save. + * + * @return $this + */ + public function setTouchedRelations(array $touches): static + { + $this->touches = $touches; + + return $this; + } +} diff --git a/src/database/src/Eloquent/Concerns/HasTimestamps.php b/src/database/src/Eloquent/Concerns/HasTimestamps.php new file mode 100644 index 000000000..9caed6ccf --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasTimestamps.php @@ -0,0 +1,206 @@ + + */ + protected static array $ignoreTimestampsOn = []; + + /** + * Update the model's update timestamp. + */ + public function touch(?string $attribute = null): bool + { + if ($attribute) { + $this->{$attribute} = $this->freshTimestamp(); + + return $this->save(); + } + + if (! $this->usesTimestamps()) { + return false; + } + + $this->updateTimestamps(); + + return $this->save(); + } + + /** + * Update the model's update timestamp without raising any events. + */ + public function touchQuietly(?string $attribute = null): bool + { + return static::withoutEvents(fn () => $this->touch($attribute)); + } + + /** + * Update the creation and update timestamps. + * + * @return $this + */ + public function updateTimestamps(): static + { + $time = $this->freshTimestamp(); + + $updatedAtColumn = $this->getUpdatedAtColumn(); + + if (! is_null($updatedAtColumn) && ! $this->isDirty($updatedAtColumn)) { + $this->setUpdatedAt($time); + } + + $createdAtColumn = $this->getCreatedAtColumn(); + + if (! $this->exists && ! is_null($createdAtColumn) && ! $this->isDirty($createdAtColumn)) { + $this->setCreatedAt($time); + } + + return $this; + } + + /** + * Set the value of the "created at" attribute. + * + * @return $this + */ + public function setCreatedAt(mixed $value): static + { + $this->{$this->getCreatedAtColumn()} = $value; + + return $this; + } + + /** + * Set the value of the "updated at" attribute. + * + * @return $this + */ + public function setUpdatedAt(mixed $value): static + { + $this->{$this->getUpdatedAtColumn()} = $value; + + return $this; + } + + /** + * Get a fresh timestamp for the model. + */ + public function freshTimestamp(): CarbonInterface + { + return Date::now(); + } + + /** + * Get a fresh timestamp for the model. + */ + public function freshTimestampString(): string + { + return $this->fromDateTime($this->freshTimestamp()); + } + + /** + * Determine if the model uses timestamps. + */ + public function usesTimestamps(): bool + { + return $this->timestamps && ! static::isIgnoringTimestamps($this::class); + } + + /** + * Get the name of the "created at" column. + */ + public function getCreatedAtColumn(): ?string + { + return static::CREATED_AT; + } + + /** + * Get the name of the "updated at" column. + */ + public function getUpdatedAtColumn(): ?string + { + return static::UPDATED_AT; + } + + /** + * Get the fully qualified "created at" column. + */ + public function getQualifiedCreatedAtColumn(): ?string + { + $column = $this->getCreatedAtColumn(); + + return $column ? $this->qualifyColumn($column) : null; + } + + /** + * Get the fully qualified "updated at" column. + */ + public function getQualifiedUpdatedAtColumn(): ?string + { + $column = $this->getUpdatedAtColumn(); + + return $column ? $this->qualifyColumn($column) : null; + } + + /** + * Disable timestamps for the current class during the given callback scope. + */ + public static function withoutTimestamps(callable $callback): mixed + { + return static::withoutTimestampsOn([static::class], $callback); + } + + /** + * Disable timestamps for the given model classes during the given callback scope. + * + * @param array $models + */ + public static function withoutTimestampsOn(array $models, callable $callback): mixed + { + // @phpstan-ignore arrayValues.list (unset() in finally block creates gaps, array_values re-indexes) + static::$ignoreTimestampsOn = array_values(array_merge(static::$ignoreTimestampsOn, $models)); + + try { + return $callback(); + } finally { + foreach ($models as $model) { + if (($key = array_search($model, static::$ignoreTimestampsOn, true)) !== false) { + unset(static::$ignoreTimestampsOn[$key]); + } + } + } + } + + /** + * Determine if the given model is ignoring timestamps / touches. + * + * @param null|class-string $class + */ + public static function isIgnoringTimestamps(?string $class = null): bool + { + $class ??= static::class; + + foreach (static::$ignoreTimestampsOn as $ignoredClass) { + if ($class === $ignoredClass || is_subclass_of($class, $ignoredClass)) { + return true; + } + } + + return false; + } +} diff --git a/src/database/src/Eloquent/Concerns/HasUlids.php b/src/database/src/Eloquent/Concerns/HasUlids.php new file mode 100644 index 000000000..443cd4448 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasUlids.php @@ -0,0 +1,28 @@ +usesUniqueIds; + } + + /** + * Generate unique keys for the model. + */ + public function setUniqueIds(): void + { + foreach ($this->uniqueIds() as $column) { + if (empty($this->{$column})) { + $this->{$column} = $this->newUniqueId(); + } + } + } + + /** + * Generate a new key for the model. + */ + public function newUniqueId(): ?string + { + return null; + } + + /** + * Get the columns that should receive a unique identifier. + * + * @return array + */ + public function uniqueIds(): array + { + return []; + } +} diff --git a/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php b/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php new file mode 100644 index 000000000..f9586a847 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasUniqueStringIds.php @@ -0,0 +1,96 @@ +usesUniqueIds = true; + } + + /** + * Get the columns that should receive a unique identifier. + * + * @return array + */ + public function uniqueIds(): array + { + return $this->usesUniqueIds() ? [$this->getKeyName()] : parent::uniqueIds(); + } + + /** + * Retrieve the model for a bound value. + * + * @param Model|Builder|Relation<*, *, *> $query + * @return Builder|Relation<*, *, *> + * + * @throws ModelNotFoundException + */ + public function resolveRouteBindingQuery(Model|Builder|Relation $query, mixed $value, ?string $field = null): Builder|Relation + { + if ($field && in_array($field, $this->uniqueIds()) && ! $this->isValidUniqueId($value)) { + $this->handleInvalidUniqueId($value, $field); + } + + if (! $field && in_array($this->getRouteKeyName(), $this->uniqueIds()) && ! $this->isValidUniqueId($value)) { + $this->handleInvalidUniqueId($value, $field); + } + + return parent::resolveRouteBindingQuery($query, $value, $field); + } + + /** + * Get the auto-incrementing key type. + */ + public function getKeyType(): string + { + if (in_array($this->getKeyName(), $this->uniqueIds())) { + return 'string'; + } + + return parent::getKeyType(); + } + + /** + * Get the value indicating whether the IDs are incrementing. + */ + public function getIncrementing(): bool + { + if (in_array($this->getKeyName(), $this->uniqueIds())) { + return false; + } + + return parent::getIncrementing(); + } + + /** + * Throw an exception for the given invalid unique ID. + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + */ + protected function handleInvalidUniqueId(mixed $value, ?string $field): never + { + throw (new ModelNotFoundException())->setModel(get_class($this), $value); + } +} diff --git a/src/database/src/Eloquent/Concerns/HasUuids.php b/src/database/src/Eloquent/Concerns/HasUuids.php new file mode 100644 index 000000000..0b811271a --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasUuids.php @@ -0,0 +1,28 @@ + + */ + protected array $hidden = []; + + /** + * The attributes that should be visible in serialization. + * + * @var array + */ + protected array $visible = []; + + /** + * Get the hidden attributes for the model. + * + * @return array + */ + public function getHidden(): array + { + return $this->hidden; + } + + /** + * Set the hidden attributes for the model. + * + * @param array $hidden + * @return $this + */ + public function setHidden(array $hidden): static + { + $this->hidden = $hidden; + + return $this; + } + + /** + * Merge new hidden attributes with existing hidden attributes on the model. + * + * @param array $hidden + * @return $this + */ + public function mergeHidden(array $hidden): static + { + $this->hidden = array_values(array_unique(array_merge($this->hidden, $hidden))); + + return $this; + } + + /** + * Get the visible attributes for the model. + * + * @return array + */ + public function getVisible(): array + { + return $this->visible; + } + + /** + * Set the visible attributes for the model. + * + * @param array $visible + * @return $this + */ + public function setVisible(array $visible): static + { + $this->visible = $visible; + + return $this; + } + + /** + * Merge new visible attributes with existing visible attributes on the model. + * + * @param array $visible + * @return $this + */ + public function mergeVisible(array $visible): static + { + $this->visible = array_values(array_unique(array_merge($this->visible, $visible))); + + return $this; + } + + /** + * Make the given, typically hidden, attributes visible. + * + * @param null|array|string $attributes + * @return $this + */ + public function makeVisible(array|string|null $attributes): static + { + $attributes = is_array($attributes) ? $attributes : func_get_args(); + + $this->hidden = array_diff($this->hidden, $attributes); + + if (! empty($this->visible)) { + $this->visible = array_values(array_unique(array_merge($this->visible, $attributes))); + } + + return $this; + } + + /** + * Make the given, typically hidden, attributes visible if the given truth test passes. + * + * @param null|array|string $attributes + * @return $this + */ + public function makeVisibleIf(bool|Closure $condition, array|string|null $attributes): static + { + return value($condition, $this) ? $this->makeVisible($attributes) : $this; + } + + /** + * Make the given, typically visible, attributes hidden. + * + * @param null|array|string $attributes + * @return $this + */ + public function makeHidden(array|string|null $attributes): static + { + $this->hidden = array_values(array_unique(array_merge( + $this->hidden, + is_array($attributes) ? $attributes : func_get_args() + ))); + + return $this; + } + + /** + * Make the given, typically visible, attributes hidden if the given truth test passes. + * + * @param null|array|string $attributes + * @return $this + */ + public function makeHiddenIf(bool|Closure $condition, array|string|null $attributes): static + { + return value($condition, $this) ? $this->makeHidden($attributes) : $this; + } +} diff --git a/src/database/src/Eloquent/Concerns/PreventsCircularRecursion.php b/src/database/src/Eloquent/Concerns/PreventsCircularRecursion.php new file mode 100644 index 000000000..30e865e15 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/PreventsCircularRecursion.php @@ -0,0 +1,95 @@ +hash, $stack)) { + return is_callable($stack[$onceable->hash]) + ? static::setRecursiveCallValue($this, $onceable->hash, call_user_func($stack[$onceable->hash])) + : $stack[$onceable->hash]; + } + + try { + static::setRecursiveCallValue($this, $onceable->hash, $default); + + return call_user_func($onceable->callable); + } finally { + static::clearRecursiveCallValue($this, $onceable->hash); + } + } + + /** + * Remove an entry from the recursion cache for an object. + */ + protected static function clearRecursiveCallValue(object $object, string $hash): void + { + if ($stack = Arr::except(static::getRecursiveCallStack($object), $hash)) { + static::getRecursionCache()->offsetSet($object, $stack); + } elseif (static::getRecursionCache()->offsetExists($object)) { + static::getRecursionCache()->offsetUnset($object); + } + } + + /** + * Get the stack of methods being called recursively for the current object. + * + * @return array + */ + protected static function getRecursiveCallStack(object $object): array + { + return static::getRecursionCache()->offsetExists($object) + ? static::getRecursionCache()->offsetGet($object) + : []; + } + + /** + * Get the current recursion cache being used by the model. + * + * @return WeakMap> + */ + protected static function getRecursionCache(): WeakMap + { + return Context::getOrSet(self::RECURSION_CACHE_CONTEXT_KEY, fn () => new WeakMap()); + } + + /** + * Set a value in the recursion cache for the given object and method. + */ + protected static function setRecursiveCallValue(object $object, string $hash, mixed $value): mixed + { + static::getRecursionCache()->offsetSet( + $object, + tap(static::getRecursiveCallStack($object), fn (&$stack) => $stack[$hash] = $value), + ); + + return static::getRecursiveCallStack($object)[$hash]; + } +} diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php new file mode 100644 index 000000000..a3eb83292 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -0,0 +1,1077 @@ +|string $relation + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): 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 + { + if (is_string($relation)) { + if (str_contains($relation, '.')) { + // @phpstan-ignore argument.type (callback template types don't narrow through forwarding) + return $this->hasNested($relation, $operator, $count, $boolean, $callback); + } + + $relation = $this->getRelationWithoutConstraints($relation); + } + + if ($relation instanceof MorphTo) { + // @phpstan-ignore argument.type (callback template types don't narrow through forwarding) + return $this->hasMorph($relation, ['*'], $operator, $count, $boolean, $callback); + } + + // If we only need to check for the existence of the relation, then we can optimize + // the subquery to only run a "where exists" clause instead of this full "count" + // clause. This will make these queries run much faster compared with a count. + $method = $this->canUseExistsForExistenceCheck($operator, $count) + ? 'getRelationExistenceQuery' + : 'getRelationExistenceCountQuery'; + + $hasQuery = $relation->{$method}( + $relation->getRelated()->newQueryWithoutRelationships(), + $this + ); + + // Next we will call any given callback as an "anonymous" scope so they can get the + // proper logical grouping of the where clauses if needed by this Eloquent query + // builder. Then, we will be ready to finalize and return this query instance. + if ($callback) { + $hasQuery->callScope($callback); + } + + return $this->addHasWhere( + $hasQuery, + $relation, + $operator, + $count, + $boolean + ); + } + + /** + * Add nested relationship count / exists conditions to the query. + * + * Sets up recursive call to whereHas until we finish the nested relation. + * + * @param (\Closure(\Hypervel\Database\Eloquent\Builder<*>): mixed)|null $callback + * @return $this + */ + protected function hasNested(string $relations, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static + { + $relations = explode('.', $relations); + + $initialRelations = [...$relations]; + + $doesntHave = $operator === '<' && $count === 1; + + if ($doesntHave) { + $operator = '>='; + $count = 1; + } + + $closure = function ($q) use (&$closure, &$relations, $operator, $count, $callback, $initialRelations) { + // If the same closure is called multiple times, reset the relation array to loop through them again... + if ($count === 1 && empty($relations)) { + $relations = [...$initialRelations]; + + array_shift($relations); + } + + // In order to nest "has", we need to add count relation constraints on the + // callback Closure. We'll do this by simply passing the Closure its own + // reference to itself so it calls itself recursively on each segment. + count($relations) > 1 + ? $q->whereHas(array_shift($relations), $closure) + : $q->has(array_shift($relations), $operator, $count, 'and', $callback); + }; + + return $this->has(array_shift($relations), $doesntHave ? '<' : '>=', 1, $boolean, $closure); + } + + /** + * Add a relationship count / exists condition to the query with an "or". + * + * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|string $relation + * @return $this + */ + public function orHas(Relation|string $relation, string $operator = '>=', Expression|int $count = 1): static + { + return $this->has($relation, $operator, $count, 'or'); + } + + /** + * Add a relationship count / exists condition to the query. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback + * @return $this + */ + public function doesntHave(Relation|string $relation, string $boolean = 'and', ?Closure $callback = null): static + { + return $this->has($relation, '<', 1, $boolean, $callback); + } + + /** + * Add a relationship count / exists condition to the query with an "or". + * + * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|string $relation + * @return $this + */ + public function orDoesntHave(Relation|string $relation): static + { + return $this->doesntHave($relation, 'or'); + } + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback + * @return $this + */ + public function whereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + { + return $this->has($relation, $operator, $count, 'and', $callback); + } + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * Also load the relationship with the same condition. + * + * @param (\Closure(\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>): mixed)|null $callback + * @return $this + */ + public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + { + return $this->whereHas(Str::before($relation, ':'), $callback, $operator, $count) + ->with($callback ? [$relation => fn ($query) => $callback($query)] : $relation); + } + + /** + * Add a relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback + * @return $this + */ + public function orWhereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + { + return $this->has($relation, $operator, $count, 'or', $callback); + } + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback + * @return $this + */ + public function whereDoesntHave(Relation|string $relation, ?Closure $callback = null): static + { + return $this->doesntHave($relation, 'and', $callback); + } + + /** + * Add a relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback + * @return $this + */ + public function orWhereDoesntHave(Relation|string $relation, ?Closure $callback = null): static + { + return $this->doesntHave($relation, 'or', $callback); + } + + /** + * Add a polymorphic relationship count / exists condition to the query. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, 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 + { + if (is_string($relation)) { + $relation = $this->getRelationWithoutConstraints($relation); + } + + $types = (array) $types; + + $checkMorphNull = $types === ['*'] + && (($operator === '<' && $count >= 1) + || ($operator === '<=' && $count >= 0) + || ($operator === '=' && $count === 0) + || ($operator === '!=' && $count >= 1)); + + if ($types === ['*']) { + // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) + $types = $this->model->newModelQuery()->distinct()->pluck($relation->getMorphType()) + ->filter() + ->map(fn ($item) => enum_value($item)) + ->all(); + } + + if (empty($types)) { + return $this->where(new Expression('0'), $operator, $count, $boolean); + } + + foreach ($types as &$type) { + $type = Relation::getMorphedModel($type) ?? $type; + } + + return $this->where(function ($query) use ($relation, $callback, $operator, $count, $types, $checkMorphNull) { + foreach ($types as $type) { + $query->orWhere(function ($query) use ($relation, $callback, $operator, $count, $type) { + $belongsTo = $this->getBelongsToRelation($relation, $type); + + if ($callback) { + $callback = function ($query) use ($callback, $type) { + return $callback($query, $type); + }; + } + + // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) + $query->where($this->qualifyColumn($relation->getMorphType()), '=', (new $type())->getMorphClass()) + ->whereHas($belongsTo, $callback, $operator, $count); + }); + } + + $query->when($checkMorphNull, fn (self $query) => $query->orWhereMorphedTo($relation, null)); + }, null, null, $boolean); + } + + /** + * Get the BelongsTo relationship for a single polymorphic type. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * @template TDeclaringModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, TDeclaringModel> $relation + * @param class-string $type + * @return \Hypervel\Database\Eloquent\Relations\BelongsTo + */ + protected function getBelongsToRelation(MorphTo $relation, string $type): BelongsTo + { + $belongsTo = Relation::noConstraints(function () use ($relation, $type) { + return $this->model->belongsTo( + $type, + $relation->getForeignKeyName(), + $relation->getOwnerKeyName() + ); + }); + + $belongsTo->getQuery()->mergeConstraintsFrom($relation->getQuery()); + + // @phpstan-ignore return.type (TModel IS TDeclaringModel in this context) + return $belongsTo; + } + + /** + * Add a polymorphic relationship count / exists condition to the query with an "or". + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param array|string $types + * @return $this + */ + public function orHasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', Expression|int $count = 1): static + { + return $this->hasMorph($relation, $types, $operator, $count, 'or'); + } + + /** + * Add a polymorphic relationship count / exists condition to the query. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback + * @return $this + */ + public function doesntHaveMorph(MorphTo|string $relation, string|array $types, string $boolean = 'and', ?Closure $callback = null): static + { + return $this->hasMorph($relation, $types, '<', 1, $boolean, $callback); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with an "or". + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param array|string $types + * @return $this + */ + public function orDoesntHaveMorph(MorphTo|string $relation, string|array $types): static + { + return $this->doesntHaveMorph($relation, $types, 'or'); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback + * @return $this + */ + public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + { + return $this->hasMorph($relation, $types, $operator, $count, 'and', $callback); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback + * @return $this + */ + public function orWhereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + { + return $this->hasMorph($relation, $types, $operator, $count, 'or', $callback); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback + * @return $this + */ + public function whereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static + { + return $this->doesntHaveMorph($relation, $types, 'and', $callback); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback + * @return $this + */ + public function orWhereDoesntHaveMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null): static + { + return $this->doesntHaveMorph($relation, $types, 'or', $callback); + } + + /** + * Add a basic where clause to a relationship query. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @return $this + */ + public function whereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + { + return $this->whereHas($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Add a basic where clause to a relationship query and eager-load the relationship with the same conditions. + * + * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|string $relation + * @return $this + */ + public function withWhereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + { + return $this->whereRelation($relation, $column, $operator, $value) + ->with([ + $relation => fn ($query) => $column instanceof Closure + ? $column($query) + : $query->where($column, $operator, $value), + ]); + } + + /** + * Add an "or where" clause to a relationship query. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @return $this + */ + public function orWhereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + { + return $this->orWhereHas($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Add a basic count / exists condition to a relationship query. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @return $this + */ + public function whereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + { + return $this->whereDoesntHave($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Add an "or where" clause to a relationship query. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @return $this + */ + public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + { + return $this->orWhereDoesntHave($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Add a polymorphic relationship condition to the query with a where clause. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\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 + { + return $this->whereHasMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a polymorphic relationship condition to the query with an "or where" clause. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\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 + { + return $this->orWhereHasMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a polymorphic relationship condition to the query with a doesn't have clause. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\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 + { + return $this->whereDoesntHaveMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a polymorphic relationship condition to the query with an "or doesn't have" clause. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation + * @param array|string $types + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\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 + { + return $this->orWhereDoesntHaveMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a morph-to relationship condition to the query. + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param null|\Hypervel\Database\Eloquent\Model|iterable|string $model + * @return $this + */ + public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $boolean = 'and'): static + { + if (is_string($relation)) { + $relation = $this->getRelationWithoutConstraints($relation); + } + + if (is_null($model)) { + // @phpstan-ignore method.notFound, return.type (getMorphType exists on MorphTo; mixin returns $this at runtime) + return $this->whereNull($relation->qualifyColumn($relation->getMorphType()), $boolean); + } + + if (is_string($model)) { + $morphMap = Relation::morphMap(); + + if (! empty($morphMap) && in_array($model, $morphMap)) { + $model = array_search($model, $morphMap, true); + } + + // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) + return $this->where($relation->qualifyColumn($relation->getMorphType()), $model, null, $boolean); + } + + $models = BaseCollection::wrap($model); + + if ($models->isEmpty()) { + throw new InvalidArgumentException('Collection given to whereMorphedTo method may not be empty.'); + } + + return $this->where(function ($query) use ($relation, $models) { + $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) { + $query->orWhere(function ($query) use ($relation, $models) { + // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) + $query->where($relation->qualifyColumn($relation->getMorphType()), $models->first()->getMorphClass()) + // @phpstan-ignore method.notFound (getForeignKeyName exists on MorphTo, not base Relation) + ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map->getKey()); + }); + }); + }, null, null, $boolean); + } + + /** + * Add a not morph-to relationship condition to the query. + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param \Hypervel\Database\Eloquent\Model|iterable|string $model + * @return $this + */ + public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string $boolean = 'and'): static + { + if (is_string($relation)) { + $relation = $this->getRelationWithoutConstraints($relation); + } + + if (is_string($model)) { + $morphMap = Relation::morphMap(); + + if (! empty($morphMap) && in_array($model, $morphMap)) { + $model = array_search($model, $morphMap, true); + } + + // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) + return $this->whereNot($relation->qualifyColumn($relation->getMorphType()), '<=>', $model, $boolean); + } + + $models = BaseCollection::wrap($model); + + if ($models->isEmpty()) { + throw new InvalidArgumentException('Collection given to whereNotMorphedTo method may not be empty.'); + } + + return $this->whereNot(function ($query) use ($relation, $models) { + $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) { + $query->orWhere(function ($query) use ($relation, $models) { + // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) + $query->where($relation->qualifyColumn($relation->getMorphType()), '<=>', $models->first()->getMorphClass()) + // @phpstan-ignore method.notFound (getForeignKeyName exists on MorphTo, not base Relation) + ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map->getKey()); + }); + }); + }, null, null, $boolean); + } + + /** + * Add a morph-to relationship condition to the query with an "or where" clause. + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param null|\Hypervel\Database\Eloquent\Model|iterable|string $model + * @return $this + */ + public function orWhereMorphedTo(MorphTo|string $relation, mixed $model): static + { + return $this->whereMorphedTo($relation, $model, 'or'); + } + + /** + * Add a not morph-to relationship condition to the query with an "or where" clause. + * + * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param \Hypervel\Database\Eloquent\Model|iterable|string $model + * @return $this + */ + public function orWhereNotMorphedTo(MorphTo|string $relation, mixed $model): static + { + return $this->whereNotMorphedTo($relation, $model, 'or'); + } + + /** + * Add a "belongs to" relationship where clause to the query. + * + * @param \Hypervel\Database\Eloquent\Collection|\Hypervel\Database\Eloquent\Model $related + * @return $this + * + * @throws \Hypervel\Database\Eloquent\RelationNotFoundException + */ + public function whereBelongsTo(mixed $related, ?string $relationshipName = null, string $boolean = 'and'): static + { + if (! $related instanceof EloquentCollection) { + $relatedCollection = $related->newCollection([$related]); + } else { + $relatedCollection = $related; + + $related = $relatedCollection->first(); + } + + if ($relatedCollection->isEmpty()) { + throw new InvalidArgumentException('Collection given to whereBelongsTo method may not be empty.'); + } + + if ($relationshipName === null) { + $relationshipName = StrCache::camel(class_basename($related)); + } + + try { + $relationship = $this->model->{$relationshipName}(); + } catch (BadMethodCallException) { + throw RelationNotFoundException::make($this->model, $relationshipName); + } + + if (! $relationship instanceof BelongsTo) { + throw RelationNotFoundException::make($this->model, $relationshipName, BelongsTo::class); + } + + $this->whereIn( + $relationship->getQualifiedForeignKeyName(), + $relatedCollection->pluck($relationship->getOwnerKeyName())->toArray(), + $boolean, + ); + + return $this; + } + + /** + * Add a "BelongsTo" relationship with an "or where" clause to the query. + * + * @return $this + * + * @throws RuntimeException + */ + public function orWhereBelongsTo(mixed $related, ?string $relationshipName = null): static + { + return $this->whereBelongsTo($related, $relationshipName, 'or'); + } + + /** + * Add a "belongs to many" relationship where clause to the query. + * + * @param \Hypervel\Database\Eloquent\Collection|\Hypervel\Database\Eloquent\Model $related + * @return $this + * + * @throws \Hypervel\Database\Eloquent\RelationNotFoundException + */ + public function whereAttachedTo(mixed $related, ?string $relationshipName = null, string $boolean = 'and'): static + { + $relatedCollection = $related instanceof EloquentCollection ? $related : $related->newCollection([$related]); + + $related = $relatedCollection->first(); + + if ($relatedCollection->isEmpty()) { + throw new InvalidArgumentException('Collection given to whereAttachedTo method may not be empty.'); + } + + if ($relationshipName === null) { + $relationshipName = StrCache::plural(StrCache::camel(class_basename($related))); + } + + try { + $relationship = $this->model->{$relationshipName}(); + } catch (BadMethodCallException) { + throw RelationNotFoundException::make($this->model, $relationshipName); + } + + if (! $relationship instanceof BelongsToMany) { + throw RelationNotFoundException::make($this->model, $relationshipName, BelongsToMany::class); + } + + $this->has( + $relationshipName, + boolean: $boolean, + callback: fn (Builder $query) => $query->whereKey($relatedCollection->pluck($related->getKeyName())), + ); + + return $this; + } + + /** + * Add a "belongs to many" relationship with an "or where" clause to the query. + * + * @return $this + * + * @throws RuntimeException + */ + public function orWhereAttachedTo(mixed $related, ?string $relationshipName = null): static + { + return $this->whereAttachedTo($related, $relationshipName, 'or'); + } + + /** + * Add subselect queries to include an aggregate value for a relationship. + * + * @return $this + */ + public function withAggregate(mixed $relations, Expression|string $column, ?string $function = null): static + { + if (empty($relations)) { + return $this; + } + + if (is_null($this->query->columns)) { + $this->query->select([$this->query->from . '.*']); + } + + $relations = is_array($relations) ? $relations : [$relations]; + + foreach ($this->parseWithRelations($relations) as $name => $constraints) { + // First we will determine if the name has been aliased using an "as" clause on the name + // and if it has we will extract the actual relationship name and the desired name of + // the resulting column. This allows multiple aggregates on the same relationships. + $segments = explode(' ', $name); + + unset($alias); + + if (count($segments) === 3 && Str::lower($segments[1]) === 'as') { + [$name, $alias] = [$segments[0], $segments[2]]; + } + + $relation = $this->getRelationWithoutConstraints($name); + + if ($function) { + if ($this->getQuery()->getGrammar()->isExpression($column)) { + $aggregateColumn = $this->getQuery()->getGrammar()->getValue($column); + } else { + $hashedColumn = $this->getRelationHashedColumn($column, $relation); + + $aggregateColumn = $this->getQuery()->getGrammar()->wrap( + $column === '*' ? $column : $relation->getRelated()->qualifyColumn($hashedColumn) + ); + } + + $expression = $function === 'exists' ? $aggregateColumn : sprintf('%s(%s)', $function, $aggregateColumn); + } else { + $expression = $this->getQuery()->getGrammar()->getValue($column); + } + + // Here, we will grab the relationship sub-query and prepare to add it to the main query + // as a sub-select. First, we'll get the "has" query and use that to get the relation + // sub-query. We'll format this relationship name and append this column if needed. + // @phpstan-ignore-next-line (return type from mixin chain loses Eloquent\Builder context) + $query = $relation->getRelationExistenceQuery( + $relation->getRelated()->newQuery(), + $this, + new Expression($expression) + )->setBindings([], 'select'); + + // @phpstan-ignore method.notFound ($query is Eloquent\Builder, not Query\Builder) + $query->callScope($constraints); + + // @phpstan-ignore method.notFound ($query is Eloquent\Builder, not Query\Builder) + $query = $query->mergeConstraintsFrom($relation->getQuery())->toBase(); + + // If the query contains certain elements like orderings / more than one column selected + // then we will remove those elements from the query so that it will execute properly + // when given to the database. Otherwise, we may receive SQL errors or poor syntax. + $query->orders = null; + $query->setBindings([], 'order'); + + if (count($query->columns) > 1) { + $query->columns = [$query->columns[0]]; + $query->bindings['select'] = []; + } + + // Finally, we will make the proper column alias to the query and run this sub-select on + // the query builder. Then, we will return the builder instance back to the developer + // for further constraint chaining that needs to take place on the query as needed. + $alias ??= StrCache::snake( + preg_replace( + '/[^[:alnum:][:space:]_]/u', + '', + sprintf('%s %s %s', $name, $function, strtolower($this->getQuery()->getGrammar()->getValue($column))) + ) + ); + + if ($function === 'exists') { + // @phpstan-ignore method.notFound (selectRaw returns $this, not Query\Builder) + $this->selectRaw( + sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrap($alias)), + $query->getBindings() + )->withCasts([$alias => 'bool']); + } else { + $this->selectSub( + $function ? $query : $query->limit(1), + $alias + ); + } + } + + return $this; + } + + /** + * Get the relation hashed column name for the given column and relation. + * + * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *> $relation + */ + protected function getRelationHashedColumn(string $column, Relation $relation): string + { + if (str_contains($column, '.')) { + return $column; + } + + return $this->getQuery()->from === $relation->getQuery()->getQuery()->from + ? "{$relation->getRelationCountHash(false)}.{$column}" + : $column; + } + + /** + * Add subselect queries to count the relations. + * + * @return $this + */ + public function withCount(mixed $relations): static + { + return $this->withAggregate(is_array($relations) ? $relations : func_get_args(), '*', 'count'); + } + + /** + * Add subselect queries to include the max of the relation's column. + * + * @return $this + */ + public function withMax(string|array $relation, Expression|string $column): static + { + return $this->withAggregate($relation, $column, 'max'); + } + + /** + * Add subselect queries to include the min of the relation's column. + * + * @return $this + */ + public function withMin(string|array $relation, Expression|string $column): static + { + return $this->withAggregate($relation, $column, 'min'); + } + + /** + * Add subselect queries to include the sum of the relation's column. + * + * @return $this + */ + public function withSum(string|array $relation, Expression|string $column): static + { + return $this->withAggregate($relation, $column, 'sum'); + } + + /** + * Add subselect queries to include the average of the relation's column. + * + * @return $this + */ + public function withAvg(string|array $relation, Expression|string $column): static + { + return $this->withAggregate($relation, $column, 'avg'); + } + + /** + * Add subselect queries to include the existence of related models. + * + * @return $this + */ + public function withExists(string|array $relation): static + { + return $this->withAggregate($relation, '*', 'exists'); + } + + /** + * Add the "has" condition where clause to the query. + * + * @param \Hypervel\Database\Eloquent\Builder<*> $hasQuery + * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *> $relation + * @return $this + */ + protected function addHasWhere(Builder $hasQuery, Relation $relation, string $operator, Expression|int $count, string $boolean): static + { + $hasQuery->mergeConstraintsFrom($relation->getQuery()); + + return $this->canUseExistsForExistenceCheck($operator, $count) + ? $this->addWhereExistsQuery($hasQuery->toBase(), $boolean, $operator === '<' && $count === 1) + : $this->addWhereCountQuery($hasQuery->toBase(), $operator, $count, $boolean); + } + + /** + * Merge the where constraints from another query to the current query. + * + * @param \Hypervel\Database\Eloquent\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 + ? $this->requalifyWhereTables( + $from->getQuery()->wheres, + $from->getQuery()->grammar->getValue($from->getQuery()->from), + $this->getModel()->getTable() + ) : $from->getQuery()->wheres; + + // 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( + $wheres, + $whereBindings + ); + } + + /** + * Updates the table name for any columns with a new qualified name. + */ + protected function requalifyWhereTables(array $wheres, string $from, string $to): array + { + return (new BaseCollection($wheres))->map(function ($where) use ($from, $to) { + return (new BaseCollection($where))->map(function ($value) use ($from, $to) { + return is_string($value) && str_starts_with($value, $from . '.') + ? $to . '.' . Str::afterLast($value, '.') + : $value; + }); + })->toArray(); + } + + /** + * Add a sub-query count clause to this query. + * + * @return $this + */ + protected function addWhereCountQuery(QueryBuilder $query, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and'): static + { + $this->query->addBinding($query->getBindings(), 'where'); + + return $this->where( + new Expression('(' . $query->toSql() . ')'), + $operator, + is_numeric($count) ? new Expression($count) : $count, + $boolean + ); + } + + /** + * Get the "has relation" base query instance. + * + * @return \Hypervel\Database\Eloquent\Relations\Relation<*, *, *> + */ + protected function getRelationWithoutConstraints(string $relation): Relation + { + return Relation::noConstraints(function () use ($relation) { + return $this->getModel()->{$relation}(); + }); + } + + /** + * Check if we can run an "exists" query to optimize performance. + */ + protected function canUseExistsForExistenceCheck(string $operator, Expression|int $count): bool + { + return ($operator === '>=' || $operator === '<') && $count === 1; + } +} diff --git a/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php b/src/database/src/Eloquent/Concerns/TransformsToResource.php similarity index 99% rename from src/core/src/Database/Eloquent/Concerns/TransformsToResource.php rename to src/database/src/Eloquent/Concerns/TransformsToResource.php index 9385c59f3..e90b3eed1 100644 --- a/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php +++ b/src/database/src/Eloquent/Concerns/TransformsToResource.php @@ -4,9 +4,9 @@ namespace Hypervel\Database\Eloquent\Concerns; -use Hyperf\Stringable\Str; use Hypervel\Database\Eloquent\Attributes\UseResource; use Hypervel\Http\Resources\Json\JsonResource; +use Hypervel\Support\Str; use LogicException; use ReflectionClass; diff --git a/src/database/src/Eloquent/Events/Booted.php b/src/database/src/Eloquent/Events/Booted.php new file mode 100644 index 000000000..ac5fe42e4 --- /dev/null +++ b/src/database/src/Eloquent/Events/Booted.php @@ -0,0 +1,9 @@ +method = $method ?? lcfirst(class_basename(static::class)); + } + + /** + * Is propagation stopped? + */ + public function isPropagationStopped(): bool + { + return $this->propagationStopped; + } + + /** + * Stop event propagation. + */ + public function stopPropagation(): static + { + $this->propagationStopped = true; + + return $this; + } +} diff --git a/src/database/src/Eloquent/Events/Replicating.php b/src/database/src/Eloquent/Events/Replicating.php new file mode 100644 index 000000000..97824b415 --- /dev/null +++ b/src/database/src/Eloquent/Events/Replicating.php @@ -0,0 +1,9 @@ +factory = $factory; + $this->pivot = $pivot; + $this->relationship = $relationship; + } + + /** + * Create the attached relationship for the given model. + */ + public function createFor(Model $model): void + { + $factoryInstance = $this->factory instanceof Factory; + + if ($factoryInstance) { + $relationship = $model->{$this->relationship}(); + } + + Collection::wrap($factoryInstance ? $this->factory->prependState($relationship->getQuery()->pendingAttributes)->create([], $model) : $this->factory)->each(function ($attachable) use ($model) { + $model->{$this->relationship}()->attach( + $attachable, + is_callable($this->pivot) ? call_user_func($this->pivot, $model) : $this->pivot + ); + }); + } + + /** + * Specify the model instances to always use when creating relationships. + * + * @return $this + */ + public function recycle(Collection $recycle): static + { + if ($this->factory instanceof Factory) { + $this->factory = $this->factory->recycle($recycle); + } + + return $this; + } +} diff --git a/src/core/src/Database/Eloquent/Factories/BelongsToRelationship.php b/src/database/src/Eloquent/Factories/BelongsToRelationship.php similarity index 79% rename from src/core/src/Database/Eloquent/Factories/BelongsToRelationship.php rename to src/database/src/Eloquent/Factories/BelongsToRelationship.php index 6c77292e7..01fc8cc51 100644 --- a/src/core/src/Database/Eloquent/Factories/BelongsToRelationship.php +++ b/src/database/src/Eloquent/Factories/BelongsToRelationship.php @@ -5,13 +5,22 @@ namespace Hypervel\Database\Eloquent\Factories; use Closure; -use Hyperf\Database\Model\Relations\BelongsTo; -use Hyperf\Database\Model\Relations\MorphTo; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Support\Collection; class BelongsToRelationship { + /** + * The related factory instance. + */ + protected Factory|Model $factory; + + /** + * The relationship name. + */ + protected string $relationship; + /** * The cached, resolved parent instance ID. */ @@ -19,23 +28,18 @@ class BelongsToRelationship /** * Create a new "belongs to" relationship definition. - * @param Factory|Model $factory the related factory instance or model - * @param string $relationship the relationship name */ - public function __construct( - protected Factory|Model $factory, - protected string $relationship - ) { + public function __construct(Factory|Model $factory, string $relationship) + { + $this->factory = $factory; + $this->relationship = $relationship; } /** * Get the parent model attributes and resolvers for the given child model. - * - * @return array */ public function attributesFor(Model $model): array { - /** @var BelongsTo|MorphTo $relationship */ $relationship = $model->{$this->relationship}(); return $relationship instanceof MorphTo ? [ @@ -66,8 +70,10 @@ protected function resolver(?string $key): Closure /** * Specify the model instances to always use when creating relationships. + * + * @return $this */ - public function recycle(Collection $recycle): self + public function recycle(Collection $recycle): static { if ($this->factory instanceof Factory) { $this->factory = $this->factory->recycle($recycle); diff --git a/src/core/src/Database/Eloquent/Factories/CrossJoinSequence.php b/src/database/src/Eloquent/Factories/CrossJoinSequence.php similarity index 80% rename from src/core/src/Database/Eloquent/Factories/CrossJoinSequence.php rename to src/database/src/Eloquent/Factories/CrossJoinSequence.php index b8c07f41c..d7398c1d5 100644 --- a/src/core/src/Database/Eloquent/Factories/CrossJoinSequence.php +++ b/src/database/src/Eloquent/Factories/CrossJoinSequence.php @@ -10,13 +10,13 @@ class CrossJoinSequence extends Sequence { /** * Create a new cross join sequence instance. - * - * @param array ...$sequences */ public function __construct(array ...$sequences) { $crossJoined = array_map( - fn ($a) => array_merge(...$a), + function ($a) { + return array_merge(...$a); + }, Arr::crossJoin(...$sequences), ); diff --git a/src/core/src/Database/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php similarity index 65% rename from src/core/src/Database/Eloquent/Factories/Factory.php rename to src/database/src/Eloquent/Factories/Factory.php index 119d8735b..be64c4414 100644 --- a/src/core/src/Database/Eloquent/Factories/Factory.php +++ b/src/database/src/Eloquent/Factories/Factory.php @@ -4,21 +4,19 @@ namespace Hypervel\Database\Eloquent\Factories; -use Carbon\Carbon; use Closure; -use Faker\Factory as FakerFactory; use Faker\Generator; -use Hyperf\Collection\Enumerable; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\Model\SoftDeletes; -use Hyperf\Support\Traits\ForwardsCalls; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; -use Hypervel\Foundation\Contracts\Application; +use Hypervel\Support\Carbon; use Hypervel\Support\Collection; +use Hypervel\Support\Enumerable; use Hypervel\Support\Str; +use Hypervel\Support\StrCache; use Hypervel\Support\Traits\Conditionable; +use Hypervel\Support\Traits\ForwardsCalls; use Hypervel\Support\Traits\Macroable; use Throwable; use UnitEnum; @@ -26,7 +24,9 @@ use function Hypervel\Support\enum_value; /** - * @template TModel of Model + * @template TModel of \Hypervel\Database\Eloquent\Model + * + * @method $this trashed() */ abstract class Factory { @@ -37,14 +37,14 @@ abstract class Factory /** * The name of the factory's corresponding model. * - * @var class-string + * @var null|class-string */ - protected $model; + protected ?string $model = null; /** * The number of models that should be generated. */ - protected ?int $count; + protected ?int $count = null; /** * The state transformations that will be applied to the model. @@ -81,23 +81,35 @@ abstract class Factory */ protected bool $expandRelationships = true; + /** + * The relationships that should not be automatically created. + */ + protected array $excludeRelationships = []; + /** * The name of the database connection that will be used to create the models. */ - protected UnitEnum|string|null $connection; + protected UnitEnum|string|null $connection = null; /** * The current Faker instance. */ - protected Generator $faker; + protected ?Generator $faker = null; /** * The default namespace where factories reside. */ - public static $namespace = 'Database\Factories\\'; + public static string $namespace = 'Database\Factories\\'; /** - * The per-class model name resolvers. + * @deprecated use $modelNameResolvers + * + * @var null|(callable(self): class-string) + */ + protected static mixed $modelNameResolver = null; + + /** + * The default model name resolvers. * * @var array> */ @@ -106,9 +118,14 @@ abstract class Factory /** * The factory name resolver. * - * @var null|(callable(class-string): class-string) + * @var null|callable */ - protected static $factoryNameResolver; + protected static mixed $factoryNameResolver = null; + + /** + * Whether to expand relationships by default. + */ + protected static bool $expandRelationshipsByDefault = true; /** * Create a new factory instance. @@ -122,7 +139,8 @@ public function __construct( ?Collection $afterCreating = null, UnitEnum|string|null $connection = null, ?Collection $recycle = null, - bool $expandRelationships = true + ?bool $expandRelationships = null, + array $excludeRelationships = [], ) { $this->count = $count; $this->states = $states ?? new Collection(); @@ -133,7 +151,8 @@ public function __construct( $this->connection = $connection; $this->recycle = $recycle ?? new Collection(); $this->faker = $this->withFaker(); - $this->expandRelationships = $expandRelationships; + $this->expandRelationships = $expandRelationships ?? self::$expandRelationshipsByDefault; + $this->excludeRelationships = $excludeRelationships; } /** @@ -141,14 +160,14 @@ public function __construct( * * @return array */ - abstract public function definition(); + abstract public function definition(): array; /** * Get a new factory instance for the given attributes. * * @param array|(callable(array): array) $attributes */ - public static function new($attributes = []): self + public static function new(callable|array $attributes = []): static { return (new static())->state($attributes)->configure(); } @@ -156,7 +175,7 @@ public static function new($attributes = []): self /** * Get a new factory instance for the given number of models. */ - public static function times(int $count): self + public static function times(int $count): static { return static::new()->count($count); } @@ -164,7 +183,7 @@ public static function times(int $count): self /** * Configure the factory. */ - public function configure(): self + public function configure(): static { return $this; } @@ -175,16 +194,15 @@ public function configure(): self * @param array|(callable(array): array) $attributes * @return array */ - public function raw(array|callable $attributes = [], ?Model $parent = null): array + public function raw(callable|array $attributes = [], ?Model $parent = null): array { if ($this->count === null) { return $this->state($attributes)->getExpandedAttributes($parent); } - return array_map( - fn () => $this->state($attributes)->getExpandedAttributes($parent), - range(1, $this->count), - ); + return array_map(function () use ($attributes, $parent) { + return $this->state($attributes)->getExpandedAttributes($parent); + }, range(1, $this->count)); } /** @@ -193,7 +211,7 @@ public function raw(array|callable $attributes = [], ?Model $parent = null): arr * @param array|(callable(array): array) $attributes * @return TModel */ - public function createOne(array|callable $attributes = []): Model + public function createOne(callable|array $attributes = []): Model { return $this->count(null)->create($attributes); } @@ -201,9 +219,10 @@ public function createOne(array|callable $attributes = []): Model /** * Create a single model and persist it to the database without dispatching any model events. * + * @param array|(callable(array): array) $attributes * @return TModel */ - public function createOneQuietly(array|callable $attributes = []): Model + public function createOneQuietly(callable|array $attributes = []): Model { return $this->count(null)->createQuietly($attributes); } @@ -212,7 +231,7 @@ public function createOneQuietly(array|callable $attributes = []): Model * Create a collection of models and persist them to the database. * * @param null|int|iterable> $records - * @return EloquentCollection + * @return \Hypervel\Database\Eloquent\Collection */ public function createMany(int|iterable|null $records = null): EloquentCollection { @@ -224,7 +243,7 @@ public function createMany(int|iterable|null $records = null): EloquentCollectio $records = array_fill(0, $records, []); } - /** @var EloquentCollection */ + // @phpstan-ignore return.type (TModel lost through Collection->map closure) return new EloquentCollection( (new Collection($records))->map(function ($record) { return $this->state($record)->create(); @@ -235,7 +254,8 @@ public function createMany(int|iterable|null $records = null): EloquentCollectio /** * Create a collection of models and persist them to the database without dispatching any model events. * - * @return EloquentCollection + * @param null|int|iterable> $records + * @return \Hypervel\Database\Eloquent\Collection */ public function createManyQuietly(int|iterable|null $records = null): EloquentCollection { @@ -246,10 +266,9 @@ public function createManyQuietly(int|iterable|null $records = null): EloquentCo * Create a collection of models and persist them to the database. * * @param array|(callable(array): array) $attributes - * @param null|TModel $parent - * @return EloquentCollection|TModel + * @return \Hypervel\Database\Eloquent\Collection|TModel */ - public function create(array|callable $attributes = [], ?Model $parent = null): EloquentCollection|Model + public function create(callable|array $attributes = [], ?Model $parent = null): EloquentCollection|Model { if (! empty($attributes)) { return $this->state($attributes)->create([], $parent); @@ -258,9 +277,9 @@ public function create(array|callable $attributes = [], ?Model $parent = null): $results = $this->make($attributes, $parent); if ($results instanceof Model) { - $this->store(new EloquentCollection([$results])); + $this->store(new Collection([$results])); - $this->callAfterCreating(new EloquentCollection([$results]), $parent); + $this->callAfterCreating(new Collection([$results]), $parent); } else { $this->store($results); @@ -274,10 +293,9 @@ public function create(array|callable $attributes = [], ?Model $parent = null): * Create a collection of models and persist them to the database without dispatching any model events. * * @param array|(callable(array): array) $attributes - * @param null|TModel $parent - * @return EloquentCollection|TModel + * @return \Hypervel\Database\Eloquent\Collection|TModel */ - public function createQuietly(array|callable $attributes = [], ?Model $parent = null): EloquentCollection|Model + public function createQuietly(callable|array $attributes = [], ?Model $parent = null): EloquentCollection|Model { return Model::withoutEvents(fn () => $this->create($attributes, $parent)); } @@ -285,10 +303,10 @@ public function createQuietly(array|callable $attributes = [], ?Model $parent = /** * Create a callback that persists a model in the database when invoked. * - * @param array|(callable(array): array) $attributes - * @return Closure(): (EloquentCollection|TModel) + * @param array $attributes + * @return Closure(): (\Hypervel\Database\Eloquent\Collection|TModel) */ - public function lazy(array|callable $attributes = [], ?Model $parent = null) + public function lazy(array $attributes = [], ?Model $parent = null): Closure { return fn () => $this->create($attributes, $parent); } @@ -296,13 +314,12 @@ public function lazy(array|callable $attributes = [], ?Model $parent = null) /** * Set the connection name on the results and store them. * - * @param EloquentCollection $results + * @param \Hypervel\Support\Collection $results */ - protected function store(EloquentCollection $results): void + protected function store(Collection $results): void { $results->each(function ($model) { if (! isset($this->connection)) { - /* @phpstan-ignore-next-line */ $model->setConnection($model->newQueryWithoutScopes()->getConnection()->getName()); } @@ -320,8 +337,6 @@ protected function store(EloquentCollection $results): void /** * Create the children for the given model. - * - * @param TModel $model */ protected function createChildren(Model $model): void { @@ -338,7 +353,7 @@ protected function createChildren(Model $model): void * @param array|(callable(array): array) $attributes * @return TModel */ - public function makeOne(array|callable $attributes = []): Model + public function makeOne(callable|array $attributes = []): Model { return $this->count(null)->make($attributes); } @@ -347,33 +362,70 @@ public function makeOne(array|callable $attributes = []): Model * Create a collection of models. * * @param array|(callable(array): array) $attributes - * @return EloquentCollection|TModel + * @return \Hypervel\Database\Eloquent\Collection|TModel */ - public function make(array|callable $attributes = [], ?Model $parent = null): EloquentCollection|Model + public function make(callable|array $attributes = [], ?Model $parent = null): EloquentCollection|Model { - if (! empty($attributes)) { - return $this->state($attributes)->make([], $parent); - } + $autoEagerLoadingEnabled = Model::isAutomaticallyEagerLoadingRelationships(); - if ($this->count === null) { - return tap($this->makeInstance($parent), function ($instance) { - $this->callAfterMaking(new EloquentCollection([$instance])); - }); + if ($autoEagerLoadingEnabled) { + Model::automaticallyEagerLoadRelationships(false); } - if ($this->count < 1) { - /** @var EloquentCollection */ - return $this->newModel()->newCollection(); + try { + if (! empty($attributes)) { + return $this->state($attributes)->make([], $parent); + } + + if ($this->count === null) { + return tap($this->makeInstance($parent), function ($instance) { + $this->callAfterMaking(new Collection([$instance])); + }); + } + + if ($this->count < 1) { + return $this->newModel()->newCollection(); + } + + $instances = $this->newModel()->newCollection(array_map(function () use ($parent) { + return $this->makeInstance($parent); + }, range(1, $this->count))); + + $this->callAfterMaking($instances); + + return $instances; + } finally { + Model::automaticallyEagerLoadRelationships($autoEagerLoadingEnabled); } + } - /** @var EloquentCollection */ - $instances = $this->newModel()->newCollection(array_map(function () use ($parent) { - return $this->makeInstance($parent); - }, range(1, $this->count))); + /** + * Insert the model records in bulk. No model events are emitted. + * + * @param array $attributes + */ + public function insert(array $attributes = [], ?Model $parent = null): void + { + $made = $this->make($attributes, $parent); + + $madeCollection = $made instanceof Collection + ? $made + : $this->newModel()->newCollection([$made]); + + $model = $madeCollection->first(); - $this->callAfterMaking($instances); + if (isset($this->connection)) { + $model->setConnection($this->connection); + } + + $query = $model->newQueryWithoutScopes(); - return $instances; + $query->fillAndInsert( + $madeCollection->withoutAppends() + ->setHidden([]) + ->map(static fn (Model $model) => $model->attributesToArray()) + ->all() + ); } /** @@ -394,8 +446,6 @@ protected function makeInstance(?Model $parent): Model /** * Get a raw attributes array for the model. - * - * @return array */ protected function getExpandedAttributes(?Model $parent): array { @@ -404,8 +454,6 @@ protected function getExpandedAttributes(?Model $parent): array /** * Get the raw attributes for the model as an array. - * - * @return array */ protected function getRawAttributes(?Model $parent): array { @@ -414,18 +462,19 @@ protected function getRawAttributes(?Model $parent): array return $this->parentResolvers(); }], $states->all())); })->reduce(function ($carry, $state) use ($parent) { + if ($state instanceof Closure) { + $state = $state->bindTo($this); + } + return array_merge($carry, $state($carry, $parent)); }, $this->definition()); } /** * Create the parent relationship resolvers (as deferred Closures). - * - * @return array */ protected function parentResolvers(): array { - /** @var array */ return $this->for ->map(fn (BelongsToRelationship $for) => $for->recycle($this->recycle)->attributesFor($this->newModel())) ->collapse() @@ -434,16 +483,16 @@ protected function parentResolvers(): array /** * Expand all attributes to their underlying values. - * - * @param array $definition - * @return array */ protected function expandAttributes(array $definition): array { return (new Collection($definition)) - ->map($evaluateRelations = function ($attribute) { + ->map($evaluateRelations = function ($attribute, $key) { if (! $this->expandRelationships && $attribute instanceof self) { $attribute = null; + } elseif ($attribute instanceof self + && array_intersect([$attribute->modelName(), $key], $this->excludeRelationships)) { + $attribute = null; } elseif ($attribute instanceof self) { $attribute = $this->getRandomRecycledModel($attribute->modelName())?->getKey() ?? $attribute->recycle($this->recycle)->create()->getKey(); @@ -458,7 +507,7 @@ protected function expandAttributes(array $definition): array $attribute = $attribute($definition); } - $attribute = $evaluateRelations($attribute); + $attribute = $evaluateRelations($attribute, $key); $definition[$key] = $attribute; @@ -470,9 +519,9 @@ protected function expandAttributes(array $definition): array /** * Add a new state transformation to the model definition. * - * @param array|(callable(array, ?Model): array) $state + * @param array|(callable(array, null|Model): array) $state */ - public function state(array|callable $state): self + public function state(callable|array $state): static { return $this->newInstance([ 'states' => $this->states->concat([ @@ -481,30 +530,40 @@ public function state(array|callable $state): self ]); } + /** + * Prepend a new state transformation to the model definition. + * + * @param array|(callable(array, null|Model): array) $state + */ + public function prependState(callable|array $state): static + { + return $this->newInstance([ + 'states' => $this->states->prepend( + is_callable($state) ? $state : fn () => $state, + ), + ]); + } + /** * Set a single model attribute. */ - public function set(int|string $key, mixed $value): self + public function set(string|int $key, mixed $value): static { return $this->state([$key => $value]); } /** * Add a new sequenced state transformation to the model definition. - * - * @param array|callable(Sequence): array ...$sequence */ - public function sequence(...$sequence): self + public function sequence(mixed ...$sequence): static { return $this->state(new Sequence(...$sequence)); } /** * Add a new sequenced state transformation to the model definition and update the pending creation count to the size of the sequence. - * - * @param array|callable(Sequence): array ...$sequence */ - public function forEachSequence(...$sequence): self + public function forEachSequence(array ...$sequence): static { return $this->state(new Sequence(...$sequence))->count(count($sequence)); } @@ -512,7 +571,7 @@ public function forEachSequence(...$sequence): self /** * Add a new cross joined sequenced state transformation to the model definition. */ - public function crossJoinSequence(...$sequence): self + public function crossJoinSequence(array ...$sequence): static { return $this->state(new CrossJoinSequence(...$sequence)); } @@ -520,7 +579,7 @@ public function crossJoinSequence(...$sequence): self /** * Define a child relationship for the model. */ - public function has(self $factory, ?string $relationship = null): self + public function has(self $factory, ?string $relationship = null): static { return $this->newInstance([ 'has' => $this->has->concat([new Relationship( @@ -535,9 +594,9 @@ public function has(self $factory, ?string $relationship = null): self */ protected function guessRelationship(string $related): string { - $guess = Str::camel(Str::plural(class_basename($related))); + $guess = StrCache::camel(StrCache::plural(class_basename($related))); - return method_exists($this->modelName(), $guess) ? $guess : Str::singular($guess); + return method_exists($this->modelName(), $guess) ? $guess : StrCache::singular($guess); } /** @@ -545,13 +604,13 @@ protected function guessRelationship(string $related): string * * @param array|(callable(): array) $pivot */ - public function hasAttached(array|EloquentCollection|Factory|Model $factory, array|callable $pivot = [], ?string $relationship = null): self + public function hasAttached(self|Collection|Model|array $factory, callable|array $pivot = [], ?string $relationship = null): static { return $this->newInstance([ 'has' => $this->has->concat([new BelongsToManyRelationship( $factory, $pivot, - $relationship ?? Str::camel(Str::plural(class_basename( + $relationship ?? StrCache::camel(StrCache::plural(class_basename( $factory instanceof Factory ? $factory->modelName() : Collection::wrap($factory)->first() @@ -563,11 +622,11 @@ public function hasAttached(array|EloquentCollection|Factory|Model $factory, arr /** * Define a parent relationship for the model. */ - public function for(Factory|Model $factory, ?string $relationship = null): self + public function for(self|Model $factory, ?string $relationship = null): static { return $this->newInstance(['for' => $this->for->concat([new BelongsToRelationship( $factory, - $relationship ?? Str::camel(class_basename( + $relationship ?? StrCache::camel(class_basename( $factory instanceof Factory ? $factory->modelName() : $factory )) )])]); @@ -576,14 +635,14 @@ public function for(Factory|Model $factory, ?string $relationship = null): self /** * Provide model instances to use instead of any nested factory calls when creating relationships. */ - public function recycle(array|Collection|EloquentCollection|Model $model): self + public function recycle(Model|Collection|array $model): static { // Group provided models by the type and merge them into existing recycle collection return $this->newInstance([ 'recycle' => $this->recycle ->flatten() ->merge( - EloquentCollection::wrap($model instanceof Model ? func_get_args() : $model) + Collection::wrap($model instanceof Model ? func_get_args() : $model) ->flatten() )->groupBy(fn ($model) => get_class($model)), ]); @@ -592,7 +651,7 @@ public function recycle(array|Collection|EloquentCollection|Model $model): self /** * Retrieve a random model of a given type from previously provided models to recycle. * - * @template TClass of Model + * @template TClass of \Hypervel\Database\Eloquent\Model * * @param class-string $modelClassName * @return null|TClass @@ -607,7 +666,7 @@ public function getRandomRecycledModel(string $modelClassName): ?Model * * @param Closure(TModel): mixed $callback */ - public function afterMaking(Closure $callback): self + public function afterMaking(Closure $callback): static { return $this->newInstance(['afterMaking' => $this->afterMaking->concat([$callback])]); } @@ -615,9 +674,9 @@ public function afterMaking(Closure $callback): self /** * Add a new "after creating" callback to the model definition. * - * @param Closure(TModel, null|Model): mixed $callback + * @param Closure(TModel, null|\Hypervel\Database\Eloquent\Model): mixed $callback */ - public function afterCreating(Closure $callback): self + public function afterCreating(Closure $callback): static { return $this->newInstance(['afterCreating' => $this->afterCreating->concat([$callback])]); } @@ -625,7 +684,7 @@ public function afterCreating(Closure $callback): self /** * Call the "after making" callbacks for the given model instances. */ - protected function callAfterMaking(EloquentCollection $instances): void + protected function callAfterMaking(Collection $instances): void { $instances->each(function ($model) { $this->afterMaking->each(function ($callback) use ($model) { @@ -637,7 +696,7 @@ protected function callAfterMaking(EloquentCollection $instances): void /** * Call the "after creating" callbacks for the given model instances. */ - protected function callAfterCreating(EloquentCollection $instances, ?Model $parent = null): void + protected function callAfterCreating(Collection $instances, ?Model $parent = null): void { $instances->each(function ($model) use ($parent) { $this->afterCreating->each(function ($callback) use ($model, $parent) { @@ -649,17 +708,19 @@ protected function callAfterCreating(EloquentCollection $instances, ?Model $pare /** * Specify how many models should be generated. */ - public function count(?int $count): self + public function count(?int $count): static { return $this->newInstance(['count' => $count]); } /** * Indicate that related parent models should not be created. + * + * @param array|string> $parents */ - public function withoutParents(): self + public function withoutParents(array $parents = []): static { - return $this->newInstance(['expandRelationships' => false]); + return $this->newInstance(! $parents ? ['expandRelationships' => false] : ['excludeRelationships' => $parents]); } /** @@ -667,26 +728,23 @@ public function withoutParents(): self */ public function getConnectionName(): ?string { - $value = enum_value($this->connection); - - return is_null($value) ? null : $value; + return enum_value($this->connection); } /** * Specify the database connection that should be used to generate models. */ - public function connection(UnitEnum|string $connection): self + public function connection(UnitEnum|string|null $connection): static { return $this->newInstance(['connection' => $connection]); } /** * Create a new instance of the factory builder with the given mutated properties. - * - * @param array $arguments */ - protected function newInstance(array $arguments = []): self + protected function newInstance(array $arguments = []): static { + // @phpstan-ignore return.type (new static preserves TModel at runtime, PHPStan can't track) return new static(...array_values(array_merge([ 'count' => $this->count, 'states' => $this->states, @@ -697,6 +755,7 @@ protected function newInstance(array $arguments = []): self 'connection' => $this->connection, 'recycle' => $this->recycle, 'expandRelationships' => $this->expandRelationships, + 'excludeRelationships' => $this->excludeRelationships, ], $arguments))); } @@ -716,12 +775,6 @@ public function newModel(array $attributes = []): Model /** * Get the name of the model that is generated by the factory. * - * Resolution order: - * 1. Explicit $model property on the factory - * 2. Per-class resolver for this specific factory class - * 3. Per-class resolver for base Factory class (global fallback) - * 4. Convention-based resolution - * * @return class-string */ public function modelName(): string @@ -730,23 +783,21 @@ public function modelName(): string return $this->model; } - $resolver = static::$modelNameResolvers[static::class] - ?? static::$modelNameResolvers[self::class] - ?? function (self $factory) { - $namespacedFactoryBasename = Str::replaceLast( - 'Factory', - '', - Str::replaceFirst(static::$namespace, '', get_class($factory)) - ); + $resolver = static::$modelNameResolvers[static::class] ?? static::$modelNameResolvers[self::class] ?? static::$modelNameResolver ?? function (self $factory) { + $namespacedFactoryBasename = Str::replaceLast( + 'Factory', + '', + Str::replaceFirst(static::$namespace, '', $factory::class) + ); - $factoryBasename = Str::replaceLast('Factory', '', class_basename($factory)); + $factoryBasename = Str::replaceLast('Factory', '', class_basename($factory)); - $appNamespace = static::appNamespace(); + $appNamespace = static::appNamespace(); - return class_exists($appNamespace . 'Models\\' . $namespacedFactoryBasename) - ? $appNamespace . 'Models\\' . $namespacedFactoryBasename - : $appNamespace . $factoryBasename; - }; + return class_exists($appNamespace . 'Models\\' . $namespacedFactoryBasename) + ? $appNamespace . 'Models\\' . $namespacedFactoryBasename + : $appNamespace . $factoryBasename; + }; return $resolver($this); } @@ -754,8 +805,6 @@ public function modelName(): string /** * Specify the callback that should be invoked to guess model names based on factory names. * - * Uses per-factory-class resolvers to avoid race conditions in concurrent environments. - * * @param callable(self): class-string $callback */ public static function guessModelNamesUsing(callable $callback): void @@ -774,12 +823,12 @@ public static function useNamespace(string $namespace): void /** * Get a new factory instance for the given model name. * - * @template TClass of Model + * @template TClass of \Hypervel\Database\Eloquent\Model * * @param class-string $modelName - * @return Factory + * @return \Hypervel\Database\Eloquent\Factories\Factory */ - public static function factoryForModel(string $modelName): Factory + public static function factoryForModel(string $modelName): self { $factory = static::resolveFactoryName($modelName); @@ -789,7 +838,7 @@ public static function factoryForModel(string $modelName): Factory /** * Specify the callback that should be invoked to guess factory names based on dynamic relationship names. * - * @param callable(class-string): class-string $callback + * @param callable(class-string<\Hypervel\Database\Eloquent\Model>): class-string<\Hypervel\Database\Eloquent\Factories\Factory> $callback */ public static function guessFactoryNamesUsing(callable $callback): void { @@ -797,29 +846,40 @@ public static function guessFactoryNamesUsing(callable $callback): void } /** - * Get a new Faker instance. + * Specify that relationships should create parent relationships by default. */ - protected function withFaker(): Generator + public static function expandRelationshipsByDefault(): void { - static $faker; + static::$expandRelationshipsByDefault = true; + } - if (! isset($faker)) { - $config = ApplicationContext::getContainer()->get(ConfigInterface::class); - $fakerLocale = $config->get('app.faker_locale', 'en_US'); + /** + * Specify that relationships should not create parent relationships by default. + */ + public static function dontExpandRelationshipsByDefault(): void + { + static::$expandRelationshipsByDefault = false; + } - $faker = FakerFactory::create($fakerLocale); + /** + * Get a new Faker instance. + */ + protected function withFaker(): ?Generator + { + if (! class_exists(Generator::class)) { + return null; } - return $faker; + return ApplicationContext::getContainer()->get(Generator::class); } /** * Get the factory name for the given model name. * - * @template TClass of Model + * @template TClass of \Hypervel\Database\Eloquent\Model * * @param class-string $modelName - * @return class-string> + * @return class-string<\Hypervel\Database\Eloquent\Factories\Factory> */ public static function resolveFactoryName(string $modelName): string { @@ -838,16 +898,14 @@ public static function resolveFactoryName(string $modelName): string /** * Get the application namespace for the application. - * - * @return string */ - protected static function appNamespace() + protected static function appNamespace(): string { try { return ApplicationContext::getContainer() ->get(Application::class) ->getNamespace(); - } catch (Throwable $e) { + } catch (Throwable) { return 'App\\'; } } @@ -857,27 +915,24 @@ protected static function appNamespace() */ public static function flushState(): void { + static::$modelNameResolver = null; static::$modelNameResolvers = []; static::$factoryNameResolver = null; static::$namespace = 'Database\Factories\\'; + static::$expandRelationshipsByDefault = true; } /** * Proxy dynamic factory methods onto their proper methods. - * - * @param string $method - * @param array $parameters - * @return mixed */ - public function __call($method, $parameters) + public function __call(string $method, array $parameters): mixed { if (static::hasMacro($method)) { return $this->macroCall($method, $parameters); } - if ($method === 'trashed' && in_array(SoftDeletes::class, class_uses_recursive($this->modelName()))) { + if ($method === 'trashed' && $this->modelName()::isSoftDeletable()) { return $this->state([ - /* @phpstan-ignore-next-line */ $this->newModel()->getDeletedAtColumn() => $parameters[0] ?? Carbon::now()->subDay(), ]); } @@ -886,7 +941,7 @@ public function __call($method, $parameters) static::throwBadMethodCallException($method); } - $relationship = Str::camel(Str::substr($method, 3)); + $relationship = StrCache::camel(Str::substr($method, 3)); $relatedModel = get_class($this->newModel()->{$relationship}()->getRelated()); @@ -899,13 +954,12 @@ public function __call($method, $parameters) if (str_starts_with($method, 'for')) { return $this->for($factory->state($parameters[0] ?? []), $relationship); } - if (str_starts_with($method, 'has')) { - return $this->has( - $factory - ->count(is_numeric($parameters[0] ?? null) ? $parameters[0] : 1) - ->state((is_callable($parameters[0] ?? null) || is_array($parameters[0] ?? null)) ? $parameters[0] : ($parameters[1] ?? [])), - $relationship - ); - } + + return $this->has( + $factory + ->count(is_numeric($parameters[0] ?? null) ? $parameters[0] : 1) + ->state((is_callable($parameters[0] ?? null) || is_array($parameters[0] ?? null)) ? $parameters[0] : ($parameters[1] ?? [])), + $relationship + ); } } diff --git a/src/core/src/Database/Eloquent/Factories/HasFactory.php b/src/database/src/Eloquent/Factories/HasFactory.php similarity index 73% rename from src/core/src/Database/Eloquent/Factories/HasFactory.php rename to src/database/src/Eloquent/Factories/HasFactory.php index 0a188a9a4..4c314e2fa 100644 --- a/src/core/src/Database/Eloquent/Factories/HasFactory.php +++ b/src/database/src/Eloquent/Factories/HasFactory.php @@ -8,7 +8,7 @@ use ReflectionClass; /** - * @template TFactory of Factory + * @template TFactory of \Hypervel\Database\Eloquent\Factories\Factory */ trait HasFactory { @@ -19,30 +19,27 @@ trait HasFactory * @param array|(callable(array, null|static): array) $state * @return TFactory */ - public static function factory(array|callable|int|null $count = null, array|callable $state = []): Factory + public static function factory(callable|array|int|null $count = null, callable|array $state = []): Factory { $factory = static::newFactory() ?? Factory::factoryForModel(static::class); - return $factory->count(is_numeric($count) ? $count : null) + return $factory + ->count(is_numeric($count) ? $count : null) ->state(is_callable($count) || is_array($count) ? $count : $state); } /** * Create a new factory instance for the model. * - * Resolution order: - * 1. Static $factory property on the model - * 2. #[UseFactory] attribute on the model class - * * @return null|TFactory */ protected static function newFactory(): ?Factory { - if (isset(static::$factory)) { + if (isset(static::$factory)) { // @phpstan-ignore staticProperty.notFound (optional property for legacy factory pattern) return static::$factory::new(); } - return static::getUseFactoryAttribute(); + return static::getUseFactoryAttribute() ?? null; } /** @@ -58,7 +55,7 @@ protected static function getUseFactoryAttribute(): ?Factory if ($attributes !== []) { $useFactory = $attributes[0]->newInstance(); - $factory = $useFactory->class::new(); + $factory = $useFactory->factoryClass::new(); $factory->guessModelNamesUsing(fn () => static::class); diff --git a/src/core/src/Database/Eloquent/Factories/Relationship.php b/src/database/src/Eloquent/Factories/Relationship.php similarity index 56% rename from src/core/src/Database/Eloquent/Factories/Relationship.php rename to src/database/src/Eloquent/Factories/Relationship.php index 03e707f32..c336db2cd 100644 --- a/src/core/src/Database/Eloquent/Factories/Relationship.php +++ b/src/database/src/Eloquent/Factories/Relationship.php @@ -4,23 +4,31 @@ namespace Hypervel\Database\Eloquent\Factories; -use Hyperf\Database\Model\Relations\BelongsToMany; -use Hyperf\Database\Model\Relations\HasOneOrMany; -use Hyperf\Database\Model\Relations\MorphOneOrMany; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\HasOneOrMany; +use Hypervel\Database\Eloquent\Relations\MorphOneOrMany; use Hypervel\Support\Collection; class Relationship { + /** + * The related factory instance. + */ + protected Factory $factory; + + /** + * The relationship name. + */ + protected string $relationship; + /** * Create a new child relationship instance. - * @param Factory $factory the related factory instance - * @param string $relationship the relationship name */ - public function __construct( - protected Factory $factory, - protected string $relationship - ) { + public function __construct(Factory $factory, string $relationship) + { + $this->factory = $factory; + $this->relationship = $relationship; } /** @@ -34,20 +42,24 @@ public function createFor(Model $parent): void $this->factory->state([ $relationship->getMorphType() => $relationship->getMorphClass(), $relationship->getForeignKeyName() => $relationship->getParentKey(), - ])->create([], $parent); + ])->prependState($relationship->getQuery()->pendingAttributes)->create([], $parent); } elseif ($relationship instanceof HasOneOrMany) { $this->factory->state([ $relationship->getForeignKeyName() => $relationship->getParentKey(), - ])->create([], $parent); + ])->prependState($relationship->getQuery()->pendingAttributes)->create([], $parent); } elseif ($relationship instanceof BelongsToMany) { - $relationship->attach($this->factory->create([], $parent)); + $relationship->attach( + $this->factory->prependState($relationship->getQuery()->pendingAttributes)->create([], $parent) + ); } } /** * Specify the model instances to always use when creating relationships. + * + * @return $this */ - public function recycle(Collection $recycle): self + public function recycle(Collection $recycle): static { $this->factory = $this->factory->recycle($recycle); diff --git a/src/core/src/Database/Eloquent/Factories/Sequence.php b/src/database/src/Eloquent/Factories/Sequence.php similarity index 63% rename from src/core/src/Database/Eloquent/Factories/Sequence.php rename to src/database/src/Eloquent/Factories/Sequence.php index bb7fce7b7..b57c853fe 100644 --- a/src/core/src/Database/Eloquent/Factories/Sequence.php +++ b/src/database/src/Eloquent/Factories/Sequence.php @@ -4,15 +4,13 @@ namespace Hypervel\Database\Eloquent\Factories; -use Closure; use Countable; +use Hypervel\Database\Eloquent\Model; class Sequence implements Countable { /** * The sequence of return values. - * - * @var array|Closure(static): array> */ protected array $sequence; @@ -28,12 +26,9 @@ class Sequence implements Countable /** * Create a new sequence instance. - * - * @param array|callable ...$sequence */ - public function __construct( - ...$sequence - ) { + public function __construct(mixed ...$sequence) + { $this->sequence = $sequence; $this->count = count($sequence); } @@ -49,13 +44,12 @@ public function count(): int /** * Get the next value in the sequence. * - * @return array + * @param array $attributes */ - public function __invoke(): array + public function __invoke(array|Model $attributes = [], ?Model $parent = null): mixed { - return tap( - value($this->sequence[$this->index % $this->count], $this), - fn () => $this->index++, - ); + return tap(value($this->sequence[$this->index % $this->count], $this, $attributes, $parent), function () { + $this->index = $this->index + 1; + }); } } diff --git a/src/database/src/Eloquent/HasBuilder.php b/src/database/src/Eloquent/HasBuilder.php new file mode 100644 index 000000000..a4102eae8 --- /dev/null +++ b/src/database/src/Eloquent/HasBuilder.php @@ -0,0 +1,124 @@ +, class-string> + */ + protected static array $resolvedCollectionClasses = []; + + /** + * Create a new Eloquent Collection instance. + * + * @param array $models + * @return TCollection + */ + public function newCollection(array $models = []): Collection + { + // @phpstan-ignore assign.propertyType (generic type narrowing loss with static property) + static::$resolvedCollectionClasses[static::class] ??= ($this->resolveCollectionFromAttribute() ?? static::$collectionClass); + + $collection = new static::$resolvedCollectionClasses[static::class]($models); + + if (Model::isAutomaticallyEagerLoadingRelationships()) { + $collection->withRelationshipAutoloading(); + } + + // @phpstan-ignore return.type (dynamic class instantiation from static property loses generic type) + return $collection; + } + + /** + * Resolve the collection class name from the CollectedBy attribute. + * + * @return null|class-string + */ + public function resolveCollectionFromAttribute(): ?string + { + $reflectionClass = new ReflectionClass(static::class); + + $attributes = $reflectionClass->getAttributes(CollectedBy::class); + + if (! isset($attributes[0]) || ! isset($attributes[0]->getArguments()[0])) { + return null; + } + + return $attributes[0]->getArguments()[0]; + } +} diff --git a/src/database/src/Eloquent/HigherOrderBuilderProxy.php b/src/database/src/Eloquent/HigherOrderBuilderProxy.php new file mode 100644 index 000000000..9be6f4503 --- /dev/null +++ b/src/database/src/Eloquent/HigherOrderBuilderProxy.php @@ -0,0 +1,32 @@ + $builder + */ + public function __construct( + protected Builder $builder, + protected string $method, + ) { + } + + /** + * Proxy a scope call onto the query builder. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->builder->{$this->method}(function ($value) use ($method, $parameters) { + return $value->{$method}(...$parameters); + }); + } +} diff --git a/src/database/src/Eloquent/InvalidCastException.php b/src/database/src/Eloquent/InvalidCastException.php new file mode 100644 index 000000000..e8fb87309 --- /dev/null +++ b/src/database/src/Eloquent/InvalidCastException.php @@ -0,0 +1,39 @@ +model = $class; + $this->column = $column; + $this->castType = $castType; + } +} diff --git a/src/database/src/Eloquent/JsonEncodingException.php b/src/database/src/Eloquent/JsonEncodingException.php new file mode 100644 index 000000000..61788fe48 --- /dev/null +++ b/src/database/src/Eloquent/JsonEncodingException.php @@ -0,0 +1,40 @@ +getKey() . '] to JSON: ' . $message); + } + + /** + * Create a new JSON encoding exception for the resource. + * + * @param \Hypervel\Http\Resources\Json\JsonResource $resource + */ + public static function forResource(object $resource, string $message): static + { + $model = $resource->resource; + + return new static('Error encoding resource [' . get_class($resource) . '] with model [' . get_class($model) . '] with ID [' . $model->getKey() . '] to JSON: ' . $message); + } + + /** + * Create a new JSON encoding exception for an attribute. + */ + public static function forAttribute(Model $model, mixed $key, string $message): static + { + $class = get_class($model); + + return new static("Unable to encode attribute [{$key}] for model [{$class}] to JSON: {$message}."); + } +} diff --git a/src/database/src/Eloquent/MassAssignmentException.php b/src/database/src/Eloquent/MassAssignmentException.php new file mode 100644 index 000000000..36d6aca42 --- /dev/null +++ b/src/database/src/Eloquent/MassAssignmentException.php @@ -0,0 +1,11 @@ +prunable(), function ($query) use ($chunkSize) { + $query->when(! $query->getQuery()->limit, function ($query) use ($chunkSize) { + $query->limit($chunkSize); + }); + }); + + $total = 0; + + $softDeletable = static::isSoftDeletable(); + + do { + $total += $count = $softDeletable + ? $query->forceDelete() + : $query->delete(); + + if ($count > 0) { + event(new ModelsPruned(static::class, $total)); + } + } while ($count > 0); + + return $total; + } + + /** + * Get the prunable model query. + * + * @return Builder + */ + public function prunable(): Builder + { + throw new LogicException('Please implement the prunable method on your model.'); + } +} diff --git a/src/database/src/Eloquent/MissingAttributeException.php b/src/database/src/Eloquent/MissingAttributeException.php new file mode 100644 index 000000000..487190cd2 --- /dev/null +++ b/src/database/src/Eloquent/MissingAttributeException.php @@ -0,0 +1,22 @@ +> */ + use HasCollection; + + /** + * Context key for storing models that should ignore touch. + */ + protected const IGNORE_ON_TOUCH_CONTEXT_KEY = '__database.model.ignoreOnTouch'; + + /** + * Context key for storing whether broadcasting is enabled. + */ + protected const BROADCASTING_CONTEXT_KEY = '__database.model.broadcasting'; + + /** + * Context key for storing whether events are disabled. + */ + protected const EVENTS_DISABLED_CONTEXT_KEY = '__database.model.eventsDisabled'; + + /** + * Context key for storing whether mass assignment is unguarded. + */ + protected const UNGUARDED_CONTEXT_KEY = '__database.model.unguarded'; + + /** + * The connection name for the model. + */ + protected UnitEnum|string|null $connection = null; + + /** + * The table associated with the model. + */ + protected ?string $table = null; + + /** + * The primary key for the model. + */ + protected string $primaryKey = 'id'; + + /** + * The "type" of the primary key ID. + */ + protected string $keyType = 'int'; + + /** + * Indicates if the IDs are auto-incrementing. + */ + public bool $incrementing = true; + + /** + * The relations to eager load on every query. + * + * @var array + */ + protected array $with = []; + + /** + * The relationship counts that should be eager loaded on every query. + * + * @var array + */ + protected array $withCount = []; + + /** + * Indicates whether lazy loading will be prevented on this model. + */ + public bool $preventsLazyLoading = false; + + /** + * The number of models to return for pagination. + */ + protected int $perPage = 15; + + /** + * Indicates if the model exists. + */ + public bool $exists = false; + + /** + * Indicates if the model was inserted during the object's lifecycle. + */ + public bool $wasRecentlyCreated = false; + + /** + * Indicates that the object's string representation should be escaped when __toString is invoked. + */ + protected bool $escapeWhenCastingToString = false; + + /** + * The connection resolver instance. + */ + protected static ?Resolver $resolver = null; + + /** + * The event dispatcher instance. + */ + protected static ?Dispatcher $dispatcher = null; + + /** + * The array of booted models. + * + * @var array, bool> + */ + protected static array $booted = []; + + /** + * The callbacks that should be executed after the model has booted. + * + * @var array, array> + */ + protected static array $bootedCallbacks = []; + + /** + * The array of trait initializers that will be called on each new instance. + * + * @var array, array> + */ + protected static array $traitInitializers = []; + + /** + * The array of global scopes on the model. + * + * @var array, array> + */ + protected static array $globalScopes = []; + + /** + * Indicates whether lazy loading should be restricted on all models. + */ + protected static bool $modelsShouldPreventLazyLoading = false; + + /** + * Indicates whether relations should be automatically loaded on all models when they are accessed. + */ + protected static bool $modelsShouldAutomaticallyEagerLoadRelationships = false; + + /** + * The callback that is responsible for handling lazy loading violations. + * + * @var null|(callable(self, string): void) + */ + protected static $lazyLoadingViolationCallback; + + /** + * Indicates if an exception should be thrown instead of silently discarding non-fillable attributes. + */ + protected static bool $modelsShouldPreventSilentlyDiscardingAttributes = false; + + /** + * The callback that is responsible for handling discarded attribute violations. + * + * @var null|(callable(self, array): void) + */ + protected static $discardedAttributeViolationCallback; + + /** + * Indicates if an exception should be thrown when trying to access a missing attribute on a retrieved model. + */ + protected static bool $modelsShouldPreventAccessingMissingAttributes = false; + + /** + * The callback that is responsible for handling missing attribute violations. + * + * @var null|(callable(self, string): void) + */ + protected static $missingAttributeViolationCallback; + + /** + * The Eloquent query builder class to use for the model. + * + * @var class-string<\Hypervel\Database\Eloquent\Builder<*>> + */ + protected static string $builder = Builder::class; + + /** + * The Eloquent collection class to use for the model. + * + * @var class-string<\Hypervel\Database\Eloquent\Collection<*, *>> + */ + protected static string $collectionClass = Collection::class; + + /** + * Cache of resolved custom builder classes per model. + * + * @var array, class-string>|false> + */ + protected static array $resolvedBuilderClasses = []; + + /** + * Cache of soft deletable models. + * + * @var array, bool> + */ + protected static array $isSoftDeletable; + + /** + * Cache of prunable models. + * + * @var array, bool> + */ + protected static array $isPrunable; + + /** + * Cache of mass prunable models. + * + * @var array, bool> + */ + protected static array $isMassPrunable; + + /** + * The name of the "created at" column. + * + * @var null|string + */ + public const CREATED_AT = 'created_at'; + + /** + * The name of the "updated at" column. + * + * @var null|string + */ + public const UPDATED_AT = 'updated_at'; + + /** + * Create a new Eloquent model instance. + * + * @param array $attributes + */ + public function __construct(array $attributes = []) + { + $this->bootIfNotBooted(); + + $this->initializeTraits(); + + $this->syncOriginal(); + + $this->fill($attributes); + } + + /** + * Check if the model needs to be booted and if so, do it. + */ + protected function bootIfNotBooted(): void + { + if (! isset(static::$booted[static::class])) { + static::$booted[static::class] = true; + + $this->fireModelEvent('booting', false); + + static::booting(); + static::boot(); + static::booted(); + + static::$bootedCallbacks[static::class] ??= []; + + foreach (static::$bootedCallbacks[static::class] as $callback) { + $callback(); + } + + $this->fireModelEvent('booted', false); + } + } + + /** + * Perform any actions required before the model boots. + */ + protected static function booting(): void + { + } + + /** + * Bootstrap the model and its traits. + */ + protected static function boot(): void + { + static::bootTraits(); + } + + /** + * Boot all of the bootable traits on the model. + */ + protected static function bootTraits(): void + { + $class = static::class; + + $booted = []; + + static::$traitInitializers[$class] = []; + + $uses = class_uses_recursive($class); + + $conventionalBootMethods = array_map(static fn ($trait) => 'boot' . class_basename($trait), $uses); + $conventionalInitMethods = array_map(static fn ($trait) => 'initialize' . class_basename($trait), $uses); + + foreach ((new ReflectionClass($class))->getMethods() as $method) { + if (! in_array($method->getName(), $booted) + && $method->isStatic() + && (in_array($method->getName(), $conventionalBootMethods) + || $method->getAttributes(Boot::class) !== [])) { + $method->invoke(null); + + $booted[] = $method->getName(); + } + + if (in_array($method->getName(), $conventionalInitMethods) + || $method->getAttributes(Initialize::class) !== []) { + static::$traitInitializers[$class][] = $method->getName(); + } + } + + static::$traitInitializers[$class] = array_unique(static::$traitInitializers[$class]); + } + + /** + * Initialize any initializable traits on the model. + */ + protected function initializeTraits(): void + { + foreach (static::$traitInitializers[static::class] as $method) { + $this->{$method}(); + } + } + + /** + * Perform any actions required after the model boots. + */ + protected static function booted(): void + { + } + + /** + * Register a closure to be executed after the model has booted. + */ + protected static function whenBooted(Closure $callback): void + { + static::$bootedCallbacks[static::class] ??= []; + + static::$bootedCallbacks[static::class][] = $callback; + } + + /** + * Clear the list of booted models so they will be re-booted. + */ + public static function clearBootedModels(): void + { + static::$booted = []; + static::$bootedCallbacks = []; + + static::$globalScopes = []; + } + + /** + * Disables relationship model touching for the current class during given callback scope. + */ + public static function withoutTouching(callable $callback): void + { + static::withoutTouchingOn([static::class], $callback); + } + + /** + * Disables relationship model touching for the given model classes during given callback scope. + * + * @param array> $models + */ + public static function withoutTouchingOn(array $models, callable $callback): void + { + /** @var list> $previous */ + $previous = Context::get(self::IGNORE_ON_TOUCH_CONTEXT_KEY, []); + Context::set(self::IGNORE_ON_TOUCH_CONTEXT_KEY, array_merge($previous, $models)); + + try { + $callback(); + } finally { + Context::set(self::IGNORE_ON_TOUCH_CONTEXT_KEY, $previous); + } + } + + /** + * Determine if the given model is ignoring touches. + * + * @param null|class-string $class + */ + public static function isIgnoringTouch(?string $class = null): bool + { + $class = $class ?: static::class; + + if (! get_class_vars($class)['timestamps'] || ! $class::UPDATED_AT) { + return true; + } + + /** @var array> $ignoreOnTouch */ + $ignoreOnTouch = Context::get(self::IGNORE_ON_TOUCH_CONTEXT_KEY, []); + + foreach ($ignoreOnTouch as $ignoredClass) { + if ($class === $ignoredClass || is_subclass_of($class, $ignoredClass)) { + return true; + } + } + + return false; + } + + /** + * Indicate that models should prevent lazy loading, silently discarding attributes, and accessing missing attributes. + */ + public static function shouldBeStrict(bool $shouldBeStrict = true): void + { + static::preventLazyLoading($shouldBeStrict); + static::preventSilentlyDiscardingAttributes($shouldBeStrict); + static::preventAccessingMissingAttributes($shouldBeStrict); + } + + /** + * Prevent model relationships from being lazy loaded. + */ + public static function preventLazyLoading(bool $value = true): void + { + static::$modelsShouldPreventLazyLoading = $value; + } + + /** + * Determine if model relationships should be automatically eager loaded when accessed. + */ + public static function automaticallyEagerLoadRelationships(bool $value = true): void + { + static::$modelsShouldAutomaticallyEagerLoadRelationships = $value; + } + + /** + * Register a callback that is responsible for handling lazy loading violations. + * + * @param null|(callable(self, string): void) $callback + */ + public static function handleLazyLoadingViolationUsing(?callable $callback): void + { + static::$lazyLoadingViolationCallback = $callback; + } + + /** + * Prevent non-fillable attributes from being silently discarded. + */ + public static function preventSilentlyDiscardingAttributes(bool $value = true): void + { + static::$modelsShouldPreventSilentlyDiscardingAttributes = $value; + } + + /** + * Register a callback that is responsible for handling discarded attribute violations. + * + * @param null|(callable(self, array): void) $callback + */ + public static function handleDiscardedAttributeViolationUsing(?callable $callback): void + { + static::$discardedAttributeViolationCallback = $callback; + } + + /** + * Prevent accessing missing attributes on retrieved models. + */ + public static function preventAccessingMissingAttributes(bool $value = true): void + { + static::$modelsShouldPreventAccessingMissingAttributes = $value; + } + + /** + * Register a callback that is responsible for handling missing attribute violations. + * + * @param null|(callable(self, string): void) $callback + */ + public static function handleMissingAttributeViolationUsing(?callable $callback): void + { + static::$missingAttributeViolationCallback = $callback; + } + + /** + * Execute a callback without broadcasting any model events for all model types. + */ + public static function withoutBroadcasting(callable $callback): mixed + { + $wasBroadcasting = Context::get(self::BROADCASTING_CONTEXT_KEY, true); + + Context::set(self::BROADCASTING_CONTEXT_KEY, false); + + try { + return $callback(); + } finally { + Context::set(self::BROADCASTING_CONTEXT_KEY, $wasBroadcasting); + } + } + + /** + * Determine if broadcasting is currently enabled. + */ + public static function isBroadcasting(): bool + { + return (bool) Context::get(self::BROADCASTING_CONTEXT_KEY, true); + } + + /** + * Fill the model with an array of attributes. + * + * @param array $attributes + * + * @throws MassAssignmentException + */ + public function fill(array $attributes): static + { + $totallyGuarded = $this->totallyGuarded(); + + $fillable = $this->fillableFromArray($attributes); + + foreach ($fillable as $key => $value) { + // The developers may choose to place some attributes in the "fillable" array + // which means only those attributes may be set through mass assignment to + // the model, and all others will just get ignored for security reasons. + if ($this->isFillable($key)) { + $this->setAttribute($key, $value); + } elseif ($totallyGuarded || static::preventsSilentlyDiscardingAttributes()) { + if (isset(static::$discardedAttributeViolationCallback)) { + call_user_func(static::$discardedAttributeViolationCallback, $this, [$key]); + } else { + throw new MassAssignmentException(sprintf( + 'Add [%s] to fillable property to allow mass assignment on [%s].', + $key, + get_class($this) + )); + } + } + } + + if (count($attributes) !== count($fillable) + && static::preventsSilentlyDiscardingAttributes()) { + $keys = array_diff(array_keys($attributes), array_keys($fillable)); + + if (isset(static::$discardedAttributeViolationCallback)) { + call_user_func(static::$discardedAttributeViolationCallback, $this, $keys); + } else { + throw new MassAssignmentException(sprintf( + 'Add fillable property [%s] to allow mass assignment on [%s].', + implode(', ', $keys), + get_class($this) + )); + } + } + + return $this; + } + + /** + * Fill the model with an array of attributes. Force mass assignment. + * + * @param array $attributes + */ + public function forceFill(array $attributes): static + { + return static::unguarded(fn () => $this->fill($attributes)); + } + + /** + * Qualify the given column name by the model's table. + */ + public function qualifyColumn(string $column): string + { + if (str_contains($column, '.')) { + return $column; + } + + return $this->getTable() . '.' . $column; + } + + /** + * Qualify the given columns with the model's table. + * + * @param array $columns + * @return array + */ + public function qualifyColumns(array $columns): array + { + return (new BaseCollection($columns)) + ->map(fn ($column) => $this->qualifyColumn($column)) + ->all(); + } + + /** + * Create a new instance of the given model. + * + * @param array $attributes + */ + public function newInstance(array $attributes = [], bool $exists = false): static + { + // This method just provides a convenient way for us to generate fresh model + // instances of this current model. It is particularly useful during the + // hydration of new objects via the Eloquent query builder instances. + $model = new static(); + + $model->exists = $exists; + + $model->setConnection( + $this->getConnectionName() + ); + + $model->setTable($this->getTable()); + + $model->mergeCasts($this->casts); + + $model->fill((array) $attributes); + + return $model; + } + + /** + * Create a new model instance that is existing. + * + * @param array|object $attributes + */ + public function newFromBuilder(array|object $attributes = [], UnitEnum|string|null $connection = null): static + { + $model = $this->newInstance([], true); + + $model->setRawAttributes((array) $attributes, true); + + $model->setConnection($connection ?? $this->getConnectionName()); + + $model->fireModelEvent('retrieved', false); + + return $model; + } + + /** + * Begin querying the model on a given connection. + * + * @return Builder + */ + public static function on(UnitEnum|string|null $connection = null): Builder + { + // First we will just create a fresh instance of this model, and then we can set the + // connection on the model so that it is used for the queries we execute, as well + // as being set on every relation we retrieve without a custom connection name. + return (new static())->setConnection($connection)->newQuery(); + } + + /** + * Begin querying the model on the write connection. + * + * @return Builder + */ + public static function onWriteConnection(): Builder + { + // @phpstan-ignore return.type (useWritePdo returns $this, mixin type inference loses Builder) + return static::query()->useWritePdo(); + } + + /** + * Get all of the models from the database. + * + * @param array|string $columns + * @return Collection + */ + public static function all(array|string $columns = ['*']): Collection + { + return static::query()->get( + is_array($columns) ? $columns : func_get_args() + ); + } + + /** + * Begin querying a model with eager loading. + * + * @param array|string $relations + * @return Builder + */ + public static function with(array|string $relations): Builder + { + return static::query()->with( + is_string($relations) ? func_get_args() : $relations + ); + } + + /** + * Eager load relations on the model. + * + * @param array|string $relations + */ + public function load(array|string $relations): static + { + $query = $this->newQueryWithoutRelationships()->with( + is_string($relations) ? func_get_args() : $relations + ); + + $query->eagerLoadRelations([$this]); + + return $this; + } + + /** + * Eager load relationships on the polymorphic relation of a model. + * + * @param array> $relations + */ + public function loadMorph(string $relation, array $relations): static + { + if (! $this->{$relation}) { + return $this; + } + + $className = get_class($this->{$relation}); + + $this->{$relation}->load($relations[$className] ?? []); + + return $this; + } + + /** + * Eager load relations on the model if they are not already eager loaded. + * + * @param array|string $relations + */ + public function loadMissing(array|string $relations): static + { + $relations = is_string($relations) ? func_get_args() : $relations; + + $this->newCollection([$this])->loadMissing($relations); + + return $this; + } + + /** + * Eager load relation's column aggregations on the model. + * + * @param array|string $relations + */ + public function loadAggregate(array|string $relations, string $column, ?string $function = null): static + { + $this->newCollection([$this])->loadAggregate($relations, $column, $function); + + return $this; + } + + /** + * Eager load relation counts on the model. + * + * @param array|string $relations + */ + public function loadCount(array|string $relations): static + { + $relations = is_string($relations) ? func_get_args() : $relations; + + return $this->loadAggregate($relations, '*', 'count'); + } + + /** + * Eager load relation max column values on the model. + * + * @param array|string $relations + */ + public function loadMax(array|string $relations, string $column): static + { + return $this->loadAggregate($relations, $column, 'max'); + } + + /** + * Eager load relation min column values on the model. + * + * @param array|string $relations + */ + public function loadMin(array|string $relations, string $column): static + { + return $this->loadAggregate($relations, $column, 'min'); + } + + /** + * Eager load relation's column summations on the model. + * + * @param array|string $relations + */ + public function loadSum(array|string $relations, string $column): static + { + return $this->loadAggregate($relations, $column, 'sum'); + } + + /** + * Eager load relation average column values on the model. + * + * @param array|string $relations + */ + public function loadAvg(array|string $relations, string $column): static + { + return $this->loadAggregate($relations, $column, 'avg'); + } + + /** + * Eager load related model existence values on the model. + * + * @param array|string $relations + */ + public function loadExists(array|string $relations): static + { + return $this->loadAggregate($relations, '*', 'exists'); + } + + /** + * Eager load relationship column aggregation on the polymorphic relation of a model. + * + * @param array> $relations + */ + public function loadMorphAggregate(string $relation, array $relations, string $column, ?string $function = null): static + { + if (! $this->{$relation}) { + return $this; + } + + $className = get_class($this->{$relation}); + + $this->{$relation}->loadAggregate($relations[$className] ?? [], $column, $function); + + return $this; + } + + /** + * Eager load relationship counts on the polymorphic relation of a model. + * + * @param array> $relations + */ + public function loadMorphCount(string $relation, array $relations): static + { + return $this->loadMorphAggregate($relation, $relations, '*', 'count'); + } + + /** + * Eager load relationship max column values on the polymorphic relation of a model. + * + * @param array> $relations + */ + public function loadMorphMax(string $relation, array $relations, string $column): static + { + return $this->loadMorphAggregate($relation, $relations, $column, 'max'); + } + + /** + * Eager load relationship min column values on the polymorphic relation of a model. + * + * @param array> $relations + */ + public function loadMorphMin(string $relation, array $relations, string $column): static + { + return $this->loadMorphAggregate($relation, $relations, $column, 'min'); + } + + /** + * Eager load relationship column summations on the polymorphic relation of a model. + * + * @param array> $relations + */ + public function loadMorphSum(string $relation, array $relations, string $column): static + { + return $this->loadMorphAggregate($relation, $relations, $column, 'sum'); + } + + /** + * Eager load relationship average column values on the polymorphic relation of a model. + * + * @param array> $relations + */ + public function loadMorphAvg(string $relation, array $relations, string $column): static + { + return $this->loadMorphAggregate($relation, $relations, $column, 'avg'); + } + + /** + * Increment a column's value by a given amount. + * + * @param array $extra + */ + public function increment(string $column, float|int $amount = 1, array $extra = []): int + { + return $this->incrementOrDecrement($column, $amount, $extra, 'increment'); + } + + /** + * Decrement a column's value by a given amount. + * + * @param array $extra + */ + public function decrement(string $column, float|int $amount = 1, array $extra = []): int + { + return $this->incrementOrDecrement($column, $amount, $extra, 'decrement'); + } + + /** + * Run the increment or decrement method on the model. + * + * @param array $extra + */ + protected function incrementOrDecrement(string $column, float|int $amount, array $extra, string $method): int|false + { + if (! $this->exists) { + return $this->newQueryWithoutRelationships()->{$method}($column, $amount, $extra); + } + + $this->{$column} = $this->isClassDeviable($column) + ? $this->deviateClassCastableAttribute($method, $column, $amount) + : $this->{$column} + ($method === 'increment' ? $amount : $amount * -1); + + $this->forceFill($extra); + + if ($this->fireModelEvent('updating') === false) { + return false; + } + + if ($this->isClassDeviable($column)) { + $amount = (clone $this)->setAttribute($column, $amount)->getAttributeFromArray($column); + } + + return tap($this->setKeysForSaveQuery($this->newQueryWithoutScopes())->{$method}($column, $amount, $extra), function () use ($column) { + $this->syncChanges(); + + $this->fireModelEvent('updated', false); + + $this->syncOriginalAttribute($column); + }); + } + + /** + * Update the model in the database. + * + * @param array $attributes + * @param array $options + */ + public function update(array $attributes = [], array $options = []): bool + { + if (! $this->exists) { + return false; + } + + return $this->fill($attributes)->save($options); + } + + /** + * Update the model in the database within a transaction. + * + * @param array $attributes + * @param array $options + * + * @throws Throwable + */ + public function updateOrFail(array $attributes = [], array $options = []): bool + { + if (! $this->exists) { + return false; + } + + return $this->fill($attributes)->saveOrFail($options); + } + + /** + * Update the model in the database without raising any events. + * + * @param array $attributes + * @param array $options + */ + public function updateQuietly(array $attributes = [], array $options = []): bool + { + if (! $this->exists) { + return false; + } + + return $this->fill($attributes)->saveQuietly($options); + } + + /** + * Increment a column's value by a given amount without raising any events. + * + * @param array $extra + */ + protected function incrementQuietly(string $column, float|int $amount = 1, array $extra = []): int|false + { + return static::withoutEvents( + fn () => $this->incrementOrDecrement($column, $amount, $extra, 'increment') + ); + } + + /** + * Decrement a column's value by a given amount without raising any events. + * + * @param array $extra + */ + protected function decrementQuietly(string $column, float|int $amount = 1, array $extra = []): int|false + { + return static::withoutEvents( + fn () => $this->incrementOrDecrement($column, $amount, $extra, 'decrement') + ); + } + + /** + * Save the model and all of its relationships. + */ + public function push(): bool + { + return $this->withoutRecursion(function () { + if (! $this->save()) { + return false; + } + + // To sync all of the relationships to the database, we will simply spin through + // the relationships and save each model via this "push" method, which allows + // us to recurse into all of these nested relations for the model instance. + foreach ($this->relations as $models) { + $models = $models instanceof Collection + ? $models->all() + : [$models]; + + foreach (array_filter($models) as $model) { + if (! $model->push()) { + return false; + } + } + } + + return true; + }, true); + } + + /** + * Save the model and all of its relationships without raising any events to the parent model. + */ + public function pushQuietly(): bool + { + return static::withoutEvents(fn () => $this->push()); + } + + /** + * Save the model to the database without raising any events. + * + * @param array $options + */ + public function saveQuietly(array $options = []): bool + { + return static::withoutEvents(fn () => $this->save($options)); + } + + /** + * Save the model to the database. + * + * @param array $options + */ + public function save(array $options = []): bool + { + $this->mergeAttributesFromCachedCasts(); + + $query = $this->newModelQuery(); + + // If the "saving" event returns false we'll bail out of the save and return + // false, indicating that the save failed. This provides a chance for any + // listeners to cancel save operations if validations fail or whatever. + if ($this->fireModelEvent('saving') === false) { + return false; + } + + // If the model already exists in the database we can just update our record + // that is already in this database using the current IDs in this "where" + // clause to only update this model. Otherwise, we'll just insert them. + if ($this->exists) { + $saved = $this->isDirty() + ? $this->performUpdate($query) : true; + } + + // If the model is brand new, we'll insert it into our database and set the + // ID attribute on the model to the value of the newly inserted row's ID + // which is typically an auto-increment value managed by the database. + else { + $saved = $this->performInsert($query); + + if (! $this->getConnectionName() + && $connection = $query->getConnection()) { + $this->setConnection($connection->getName()); + } + } + + // If the model is successfully saved, we need to do a few more things once + // that is done. We will call the "saved" method here to run any actions + // we need to happen after a model gets successfully saved right here. + if ($saved) { + $this->finishSave($options); + } + + return $saved; + } + + /** + * Save the model to the database within a transaction. + * + * @param array $options + * + * @throws Throwable + */ + public function saveOrFail(array $options = []): bool + { + return $this->getConnection()->transaction(fn () => $this->save($options)); + } + + /** + * Perform any actions that are necessary after the model is saved. + * + * @param array $options + */ + protected function finishSave(array $options): void + { + $this->fireModelEvent('saved', false); + + if ($this->isDirty() && ($options['touch'] ?? true)) { + $this->touchOwners(); + } + + $this->syncOriginal(); + } + + /** + * Perform a model update operation. + * + * @param Builder $query + */ + protected function performUpdate(Builder $query): bool + { + // If the updating event returns false, we will cancel the update operation so + // developers can hook Validation systems into their models and cancel this + // operation if the model does not pass validation. Otherwise, we update. + if ($this->fireModelEvent('updating') === false) { + return false; + } + + // First we need to create a fresh query instance and touch the creation and + // update timestamp on the model which are maintained by us for developer + // convenience. Then we will just continue saving the model instances. + if ($this->usesTimestamps()) { + $this->updateTimestamps(); + } + + // Once we have run the update operation, we will fire the "updated" event for + // this model instance. This will allow developers to hook into these after + // models are updated, giving them a chance to do any special processing. + $dirty = $this->getDirtyForUpdate(); + + if (count($dirty) > 0) { + $this->setKeysForSaveQuery($query)->update($dirty); + + $this->syncChanges(); + + $this->fireModelEvent('updated', false); + } + + return true; + } + + /** + * Set the keys for a select query. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function setKeysForSelectQuery(Builder $query): Builder + { + $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery()); + + return $query; + } + + /** + * Get the primary key value for a select query. + */ + protected function getKeyForSelectQuery(): mixed + { + return $this->original[$this->getKeyName()] ?? $this->getKey(); + } + + /** + * Set the keys for a save update query. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function setKeysForSaveQuery(Builder $query): Builder + { + $query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery()); + + return $query; + } + + /** + * Get the primary key value for a save query. + */ + protected function getKeyForSaveQuery(): mixed + { + return $this->original[$this->getKeyName()] ?? $this->getKey(); + } + + /** + * Perform a model insert operation. + * + * @param Builder $query + */ + protected function performInsert(Builder $query): bool + { + if ($this->usesUniqueIds()) { + $this->setUniqueIds(); + } + + if ($this->fireModelEvent('creating') === false) { + return false; + } + + // First we'll need to create a fresh query instance and touch the creation and + // update timestamps on this model, which are maintained by us for developer + // convenience. After, we will just continue saving these model instances. + if ($this->usesTimestamps()) { + $this->updateTimestamps(); + } + + // If the model has an incrementing key, we can use the "insertGetId" method on + // the query builder, which will give us back the final inserted ID for this + // table from the database. Not all tables have to be incrementing though. + $attributes = $this->getAttributesForInsert(); + + if ($this->getIncrementing()) { + $this->insertAndSetId($query, $attributes); + } + + // If the table isn't incrementing we'll simply insert these attributes as they + // are. These attribute arrays must contain an "id" column previously placed + // there by the developer as the manually determined key for these models. + else { + if (empty($attributes)) { + return true; + } + + $query->insert($attributes); + } + + // We will go ahead and set the exists property to true, so that it is set when + // the created event is fired, just in case the developer tries to update it + // during the event. This will allow them to do so and run an update here. + $this->exists = true; + + $this->wasRecentlyCreated = true; + + $this->fireModelEvent('created', false); + + return true; + } + + /** + * Insert the given attributes and set the ID on the model. + * + * @param Builder $query + * @param array $attributes + */ + protected function insertAndSetId(Builder $query, array $attributes): void + { + $id = $query->insertGetId($attributes, $keyName = $this->getKeyName()); + + $this->setAttribute($keyName, $id); + } + + /** + * Destroy the models for the given IDs. + * + * @param array|BaseCollection|Collection|int|string $ids + */ + public static function destroy(Collection|BaseCollection|array|int|string $ids): int + { + if ($ids instanceof EloquentCollection) { + $ids = $ids->modelKeys(); + } + + if ($ids instanceof BaseCollection) { + $ids = $ids->all(); + } + + $ids = is_array($ids) ? $ids : func_get_args(); + + if (count($ids) === 0) { + return 0; + } + + // We will actually pull the models from the database table and call delete on + // each of them individually so that their events get fired properly with a + // correct set of attributes in case the developers wants to check these. + $key = ($instance = new static())->getKeyName(); + + $count = 0; + + foreach ($instance->whereIn($key, $ids)->get() as $model) { + if ($model->delete()) { + ++$count; + } + } + + return $count; + } + + /** + * Delete the model from the database. + * + * Returns bool|null for standard models, int (affected rows) for pivot models. + * + * @throws LogicException + */ + public function delete(): int|bool|null + { + $this->mergeAttributesFromCachedCasts(); + + // @phpstan-ignore function.impossibleType (defensive: users may set $primaryKey = null) + if (is_null($this->getKeyName())) { + throw new LogicException('No primary key defined on model.'); + } + + // If the model doesn't exist, there is nothing to delete so we'll just return + // immediately and not do anything else. Otherwise, we will continue with a + // deletion process on the model, firing the proper events, and so forth. + if (! $this->exists) { + return null; + } + + if ($this->fireModelEvent('deleting') === false) { + return false; + } + + // Here, we'll touch the owning models, verifying these timestamps get updated + // for the models. This will allow any caching to get broken on the parents + // by the timestamp. Then we will go ahead and delete the model instance. + $this->touchOwners(); + + $this->performDeleteOnModel(); + + // Once the model has been deleted, we will fire off the deleted event so that + // the developers may hook into post-delete operations. We will then return + // a boolean true as the delete is presumably successful on the database. + $this->fireModelEvent('deleted', false); + + return true; + } + + /** + * Delete the model from the database without raising any events. + */ + public function deleteQuietly(): ?bool + { + return static::withoutEvents(fn () => $this->delete()); + } + + /** + * Delete the model from the database within a transaction. + * + * @throws Throwable + */ + public function deleteOrFail(): ?bool + { + if (! $this->exists) { + return false; + } + + return $this->getConnection()->transaction(fn () => $this->delete()); + } + + /** + * Force a hard delete on a soft deleted model. + * + * This method protects developers from running forceDelete when the trait is missing. + */ + public function forceDelete(): ?bool + { + return $this->delete(); + } + + /** + * Force a hard destroy on a soft deleted model. + * + * This method protects developers from running forceDestroy when the trait is missing. + * + * @param array|BaseCollection|Collection|int|string $ids + */ + public static function forceDestroy(Collection|BaseCollection|array|int|string $ids): int + { + return static::destroy($ids); + } + + /** + * Perform the actual delete query on this model instance. + */ + protected function performDeleteOnModel(): void + { + $this->setKeysForSaveQuery($this->newModelQuery())->delete(); + + $this->exists = false; + } + + /** + * Begin querying the model. + * + * @return Builder + */ + public static function query(): Builder + { + return (new static())->newQuery(); + } + + /** + * Get a new query builder for the model's table. + * + * @return Builder + */ + public function newQuery(): Builder + { + return $this->registerGlobalScopes($this->newQueryWithoutScopes()); + } + + /** + * Get a new query builder that doesn't have any global scopes or eager loading. + * + * @return Builder + */ + public function newModelQuery(): Builder + { + // @phpstan-ignore return.type (template covariance: $this vs static in setModel) + return $this->newEloquentBuilder( + $this->newBaseQueryBuilder() + )->setModel($this); + } + + /** + * Get a new query builder with no relationships loaded. + * + * @return Builder + */ + public function newQueryWithoutRelationships(): Builder + { + return $this->registerGlobalScopes($this->newModelQuery()); + } + + /** + * Register the global scopes for this builder instance. + * + * @param Builder $builder + * @return Builder + */ + public function registerGlobalScopes(Builder $builder): Builder + { + foreach ($this->getGlobalScopes() as $identifier => $scope) { + $builder->withGlobalScope($identifier, $scope); + } + + return $builder; + } + + /** + * Get a new query builder that doesn't have any global scopes. + * + * @return Builder + */ + public function newQueryWithoutScopes(): Builder + { + return $this->newModelQuery() + ->with($this->with) + ->withCount($this->withCount); + } + + /** + * Get a new query instance without a given scope. + * + * @return Builder + */ + public function newQueryWithoutScope(Scope|string $scope): Builder + { + return $this->newQuery()->withoutGlobalScope($scope); + } + + /** + * Get a new query to restore one or more models by their queueable IDs. + * + * @return \Hypervel\Database\Eloquent\Builder + */ + public function newQueryForRestoration(array|int|string $ids): Builder + { + return $this->newQueryWithoutScopes()->whereKey($ids); + } + + /** + * Create a new Eloquent query builder for the model. + * + * @return Builder<*> + */ + public function newEloquentBuilder(QueryBuilder $query): Builder + { + $builderClass = static::$resolvedBuilderClasses[static::class] + ??= $this->resolveCustomBuilderClass(); + + // @phpstan-ignore function.alreadyNarrowedType (defensive: validates custom builder class at runtime) + if ($builderClass && is_subclass_of($builderClass, Builder::class)) { + return new $builderClass($query); + } + + return new static::$builder($query); + } + + /** + * Resolve the custom Eloquent builder class from the model attributes. + * + * @return class-string|false + */ + protected function resolveCustomBuilderClass(): string|false + { + $attributes = (new ReflectionClass($this)) + ->getAttributes(UseEloquentBuilder::class); + + return ! empty($attributes) + ? $attributes[0]->newInstance()->builderClass + : false; + } + + /** + * Get a new query builder instance for the connection. + */ + protected function newBaseQueryBuilder(): QueryBuilder + { + return $this->getConnection()->query(); + } + + /** + * Create a new pivot model instance. + * + * @param array $attributes + * @param null|class-string $using + */ + public function newPivot(self $parent, array $attributes, string $table, bool $exists, ?string $using = null): Pivot + { + return $using ? $using::fromRawAttributes($parent, $attributes, $table, $exists) + : Pivot::fromAttributes($parent, $attributes, $table, $exists); + } + + /** + * Determine if the model has a given scope. + */ + public function hasNamedScope(string $scope): bool + { + return method_exists($this, 'scope' . ucfirst($scope)) + || static::isScopeMethodWithAttribute($scope); + } + + /** + * Apply the given named scope if possible. + * + * @param array $parameters + */ + public function callNamedScope(string $scope, array $parameters = []): mixed + { + if ($this->isScopeMethodWithAttribute($scope)) { + return $this->{$scope}(...$parameters); + } + + return $this->{'scope' . ucfirst($scope)}(...$parameters); + } + + /** + * Determine if the given method has a scope attribute. + */ + protected static function isScopeMethodWithAttribute(string $method): bool + { + return method_exists(static::class, $method) + && (new ReflectionMethod(static::class, $method)) + ->getAttributes(LocalScope::class) !== []; + } + + /** + * Convert the model instance to an array. + */ + public function toArray(): array + { + return $this->withoutRecursion( + fn () => array_merge($this->attributesToArray(), $this->relationsToArray()), + fn () => $this->attributesToArray(), + ); + } + + /** + * Convert the model instance to JSON. + * + * @throws \Hypervel\Database\Eloquent\JsonEncodingException + */ + public function toJson(int $options = 0): string + { + try { + $json = json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw JsonEncodingException::forModel($this, $e->getMessage()); + } + + return $json; + } + + /** + * Convert the model instance to pretty print formatted JSON. + * + * @throws \Hypervel\Database\Eloquent\JsonEncodingException + */ + public function toPrettyJson(int $options = 0): string + { + return $this->toJson(JSON_PRETTY_PRINT | $options); + } + + /** + * Convert the object into something JSON serializable. + */ + public function jsonSerialize(): mixed + { + return $this->toArray(); + } + + /** + * Reload a fresh model instance from the database. + * + * @param array|string $with + */ + public function fresh(array|string $with = []): ?static + { + if (! $this->exists) { + return null; + } + + return $this->setKeysForSelectQuery($this->newQueryWithoutScopes()) + ->useWritePdo() + ->with(is_string($with) ? func_get_args() : $with) + ->first(); + } + + /** + * Reload the current model instance with fresh attributes from the database. + */ + public function refresh(): static + { + if (! $this->exists) { + return $this; + } + + $this->setRawAttributes( + $this->setKeysForSelectQuery($this->newQueryWithoutScopes()) + ->useWritePdo() + ->firstOrFail() + ->attributes + ); + + $this->load((new BaseCollection($this->relations))->reject( + fn ($relation) => $relation instanceof Pivot + || (is_object($relation) && in_array(AsPivot::class, class_uses_recursive($relation), true)) + )->keys()->all()); + + $this->syncOriginal(); + + return $this; + } + + /** + * Clone the model into a new, non-existing instance. + * + * @param null|array $except + */ + public function replicate(?array $except = null): static + { + $defaults = array_values(array_filter([ + $this->getKeyName(), + $this->getCreatedAtColumn(), + $this->getUpdatedAtColumn(), + ...$this->uniqueIds(), + 'laravel_through_key', + ])); + + $attributes = Arr::except( + $this->getAttributes(), + $except ? array_unique(array_merge($except, $defaults)) : $defaults + ); + + return tap(new static(), function ($instance) use ($attributes) { + $instance->setRawAttributes($attributes); + + $instance->setRelations($this->relations); + + $instance->fireModelEvent('replicating', false); + }); + } + + /** + * Clone the model into a new, non-existing instance without raising any events. + * + * @param null|array $except + */ + public function replicateQuietly(?array $except = null): static + { + return static::withoutEvents(fn () => $this->replicate($except)); + } + + /** + * Determine if two models have the same ID and belong to the same table. + */ + public function is(?self $model): bool + { + return ! is_null($model) + && $this->getKey() === $model->getKey() + && $this->getTable() === $model->getTable() + && $this->getConnectionName() === $model->getConnectionName(); + } + + /** + * Determine if two models are not the same. + */ + public function isNot(?self $model): bool + { + return ! $this->is($model); + } + + /** + * Get the database connection for the model. + */ + public function getConnection(): Connection + { + return static::resolveConnection($this->getConnectionName()); + } + + /** + * Get the current connection name for the model. + */ + public function getConnectionName(): ?string + { + return enum_value($this->connection); + } + + /** + * Set the connection associated with the model. + */ + public function setConnection(UnitEnum|string|null $name): static + { + $this->connection = $name; + + return $this; + } + + /** + * Resolve a connection instance. + */ + public static function resolveConnection(UnitEnum|string|null $connection = null): Connection + { + // @phpstan-ignore return.type (resolver interface returns ConnectionInterface, but concrete always returns Connection) + return static::$resolver->connection($connection); + } + + /** + * Get the connection resolver instance. + */ + public static function getConnectionResolver(): ?Resolver + { + return static::$resolver; + } + + /** + * Set the connection resolver instance. + */ + public static function setConnectionResolver(Resolver $resolver): void + { + static::$resolver = $resolver; + } + + /** + * Unset the connection resolver for models. + */ + public static function unsetConnectionResolver(): void + { + static::$resolver = null; + } + + /** + * Get the table associated with the model. + */ + public function getTable(): string + { + return $this->table ?? StrCache::snake(StrCache::pluralStudly(class_basename($this))); + } + + /** + * Set the table associated with the model. + */ + public function setTable(string $table): static + { + $this->table = $table; + + return $this; + } + + /** + * Get the primary key for the model. + */ + public function getKeyName(): string + { + return $this->primaryKey; + } + + /** + * Set the primary key for the model. + */ + public function setKeyName(string $key): static + { + $this->primaryKey = $key; + + return $this; + } + + /** + * Get the table qualified key name. + */ + public function getQualifiedKeyName(): string + { + return $this->qualifyColumn($this->getKeyName()); + } + + /** + * Get the auto-incrementing key type. + */ + public function getKeyType(): string + { + return $this->keyType; + } + + /** + * Set the data type for the primary key. + */ + public function setKeyType(string $type): static + { + $this->keyType = $type; + + return $this; + } + + /** + * Get the value indicating whether the IDs are incrementing. + */ + public function getIncrementing(): bool + { + return $this->incrementing; + } + + /** + * Set whether IDs are incrementing. + */ + public function setIncrementing(bool $value): static + { + $this->incrementing = $value; + + return $this; + } + + /** + * Get the value of the model's primary key. + */ + public function getKey(): mixed + { + return $this->getAttribute($this->getKeyName()); + } + + /** + * Get the queueable identity for the entity. + */ + public function getQueueableId(): mixed + { + return $this->getKey(); + } + + /** + * Get the queueable relationships for the entity. + */ + public function getQueueableRelations(): array + { + return $this->withoutRecursion(function () { + $relations = []; + + foreach ($this->getRelations() as $key => $relation) { + if (! method_exists($this, $key)) { + continue; + } + + $relations[] = $key; + + if ($relation instanceof QueueableCollection) { + foreach ($relation->getQueueableRelations() as $collectionValue) { + $relations[] = $key . '.' . $collectionValue; + } + } + + if ($relation instanceof QueueableEntity) { + foreach ($relation->getQueueableRelations() as $entityValue) { + $relations[] = $key . '.' . $entityValue; + } + } + } + + return array_unique($relations); + }, []); + } + + /** + * Get the queueable connection for the entity. + */ + public function getQueueableConnection(): ?string + { + return $this->getConnectionName(); + } + + /** + * Get the value of the model's route key. + */ + public function getRouteKey(): mixed + { + return $this->getAttribute($this->getRouteKeyName()); + } + + /** + * Get the route key for the model. + */ + public function getRouteKeyName(): string + { + return $this->getKeyName(); + } + + /** + * Retrieve the model for a bound value. + */ + public function resolveRouteBinding(mixed $value, ?string $field = null): ?self + { + return $this->resolveRouteBindingQuery($this, $value, $field)->first(); + } + + /** + * Retrieve the model for a bound value. + */ + public function resolveSoftDeletableRouteBinding(mixed $value, ?string $field = null): ?self + { + return $this->resolveRouteBindingQuery($this, $value, $field)->withTrashed()->first(); + } + + /** + * Retrieve the child model for a bound value. + */ + public function resolveChildRouteBinding(string $childType, mixed $value, ?string $field): ?self + { + return $this->resolveChildRouteBindingQuery($childType, $value, $field)->first(); + } + + /** + * Retrieve the child model for a bound value. + */ + public function resolveSoftDeletableChildRouteBinding(string $childType, mixed $value, ?string $field): ?self + { + return $this->resolveChildRouteBindingQuery($childType, $value, $field)->withTrashed()->first(); + } + + /** + * Retrieve the child model query for a bound value. + * + * @return Relations\Relation + */ + protected function resolveChildRouteBindingQuery(string $childType, mixed $value, ?string $field): Relations\Relation + { + $relationship = $this->{$this->childRouteBindingRelationshipName($childType)}(); + + $field = $field ?: $relationship->getRelated()->getRouteKeyName(); + + if ($relationship instanceof HasManyThrough + || $relationship instanceof BelongsToMany) { + $field = $relationship->getRelated()->qualifyColumn($field); + } + + return $relationship instanceof Model + ? $relationship->resolveRouteBindingQuery($relationship, $value, $field) + : $relationship->getRelated()->resolveRouteBindingQuery($relationship, $value, $field); + } + + /** + * Retrieve the child route model binding relationship name for the given child type. + */ + protected function childRouteBindingRelationshipName(string $childType): string + { + return StrCache::plural(StrCache::camel($childType)); + } + + /** + * Retrieve the model for a bound value. + * + * @param self|Builder|Relations\Relation<*, *, *> $query + * @return Builder|Relations\Relation<*, *, *> + */ + public function resolveRouteBindingQuery(self|Builder|Relations\Relation $query, mixed $value, ?string $field = null): Builder|Relations\Relation + { + return $query->where($field ?? $this->getRouteKeyName(), $value); + } + + /** + * Get the default foreign key name for the model. + */ + public function getForeignKey(): string + { + return StrCache::snake(class_basename($this)) . '_' . $this->getKeyName(); + } + + /** + * Get the number of models to return per page. + */ + public function getPerPage(): int + { + return $this->perPage; + } + + /** + * Set the number of models to return per page. + */ + public function setPerPage(int $perPage): static + { + $this->perPage = $perPage; + + return $this; + } + + /** + * Determine if the model is soft deletable. + */ + public static function isSoftDeletable(): bool + { + return static::$isSoftDeletable[static::class] ??= in_array(SoftDeletes::class, class_uses_recursive(static::class)); + } + + /** + * Determine if the model is prunable. + */ + protected function isPrunable(): bool + { + return self::$isPrunable[static::class] ??= in_array(Prunable::class, class_uses_recursive(static::class)) || static::isMassPrunable(); + } + + /** + * Determine if the model is mass prunable. + */ + protected function isMassPrunable(): bool + { + return self::$isMassPrunable[static::class] ??= in_array(MassPrunable::class, class_uses_recursive(static::class)); + } + + /** + * Determine if lazy loading is disabled. + */ + public static function preventsLazyLoading(): bool + { + return static::$modelsShouldPreventLazyLoading; + } + + /** + * Determine if relationships are being automatically eager loaded when accessed. + */ + public static function isAutomaticallyEagerLoadingRelationships(): bool + { + return static::$modelsShouldAutomaticallyEagerLoadRelationships; + } + + /** + * Determine if discarding guarded attribute fills is disabled. + */ + public static function preventsSilentlyDiscardingAttributes(): bool + { + return static::$modelsShouldPreventSilentlyDiscardingAttributes; + } + + /** + * Determine if accessing missing attributes is disabled. + */ + public static function preventsAccessingMissingAttributes(): bool + { + return static::$modelsShouldPreventAccessingMissingAttributes; + } + + /** + * Get the broadcast channel route definition that is associated with the given entity. + */ + public function broadcastChannelRoute(): string + { + return str_replace('\\', '.', get_class($this)) . '.{' . Str::camel(class_basename($this)) . '}'; + } + + /** + * Get the broadcast channel name that is associated with the given entity. + */ + public function broadcastChannel(): string + { + return str_replace('\\', '.', get_class($this)) . '.' . $this->getKey(); + } + + /** + * Dynamically retrieve attributes on the model. + */ + public function __get(string $key): mixed + { + return $this->getAttribute($key); + } + + /** + * Dynamically set attributes on the model. + */ + public function __set(string $key, mixed $value): void + { + $this->setAttribute($key, $value); + } + + /** + * Determine if the given attribute exists. + * + * @param mixed $offset + */ + public function offsetExists($offset): bool + { + $shouldPrevent = static::$modelsShouldPreventAccessingMissingAttributes; + + static::$modelsShouldPreventAccessingMissingAttributes = false; + + try { + return ! is_null($this->getAttribute($offset)); + } finally { + static::$modelsShouldPreventAccessingMissingAttributes = $shouldPrevent; + } + } + + /** + * Get the value for a given offset. + * + * @param mixed $offset + */ + public function offsetGet($offset): mixed + { + return $this->getAttribute($offset); + } + + /** + * Set the value for a given offset. + * + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + $this->setAttribute($offset, $value); + } + + /** + * Unset the value for a given offset. + * + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + unset( + $this->attributes[$offset], + $this->relations[$offset], + $this->attributeCastCache[$offset], + $this->classCastCache[$offset] + ); + } + + /** + * Determine if an attribute or relation exists on the model. + */ + public function __isset(string $key): bool + { + return $this->offsetExists($key); + } + + /** + * Unset an attribute on the model. + */ + public function __unset(string $key): void + { + $this->offsetUnset($key); + } + + /** + * Handle dynamic method calls into the model. + * + * @param array $parameters + */ + public function __call(string $method, array $parameters): mixed + { + if (in_array($method, ['increment', 'decrement', 'incrementQuietly', 'decrementQuietly'])) { + return $this->{$method}(...$parameters); + } + + if ($resolver = $this->relationResolver(static::class, $method)) { + return $resolver($this); + } + + if (Str::startsWith($method, 'through') + && method_exists($this, $relationMethod = (new SupportStringable($method))->after('through')->lcfirst()->toString())) { + return $this->through($relationMethod); + } + + return $this->forwardCallTo($this->newQuery(), $method, $parameters); + } + + /** + * Handle dynamic static method calls into the model. + * + * @param array $parameters + */ + public static function __callStatic(string $method, array $parameters): mixed + { + if (static::isScopeMethodWithAttribute($method)) { + return static::query()->{$method}(...$parameters); + } + + return (new static())->{$method}(...$parameters); + } + + /** + * Convert the model to its string representation. + */ + public function __toString(): string + { + return $this->escapeWhenCastingToString + ? e($this->toJson()) + : $this->toJson(); + } + + /** + * Indicate that the object's string representation should be escaped when __toString is invoked. + */ + public function escapeWhenCastingToString(bool $escape = true): static + { + $this->escapeWhenCastingToString = $escape; + + return $this; + } + + /** + * Prepare the object for serialization. + * + * @return array + */ + public function __sleep(): array + { + $this->mergeAttributesFromCachedCasts(); + + $this->classCastCache = []; + $this->attributeCastCache = []; + $this->relationAutoloadCallback = null; + $this->relationAutoloadContext = null; + + $keys = get_object_vars($this); + + if (version_compare(PHP_VERSION, '8.4.0', '>=')) { + foreach ((new ReflectionClass($this))->getProperties() as $property) { + // @phpstan-ignore method.notFound (PHP 8.4+ only, guarded by version check) + if ($property->hasHooks()) { + unset($keys[$property->getName()]); + } + } + } + + return array_keys($keys); + } + + /** + * When a model is being unserialized, check if it needs to be booted. + */ + public function __wakeup(): void + { + $this->bootIfNotBooted(); + + $this->initializeTraits(); + + if (static::isAutomaticallyEagerLoadingRelationships()) { + $this->withRelationshipAutoloading(); + } + } +} diff --git a/src/database/src/Eloquent/ModelInspector.php b/src/database/src/Eloquent/ModelInspector.php new file mode 100644 index 000000000..bc66307b7 --- /dev/null +++ b/src/database/src/Eloquent/ModelInspector.php @@ -0,0 +1,388 @@ + + */ + protected array $relationMethods = [ + 'hasMany', + 'hasManyThrough', + 'hasOneThrough', + 'belongsToMany', + 'hasOne', + 'belongsTo', + 'morphOne', + 'morphTo', + 'morphMany', + 'morphToMany', + 'morphedByMany', + ]; + + /** + * Create a new model inspector instance. + */ + public function __construct( + protected Application $app, + ) { + } + + /** + * Extract model details for the given model. + * + * @param class-string|string $model + * @return array{class: class-string, database: null|string, table: string, policy: null|class-string, attributes: BaseCollection>, relations: BaseCollection>, events: BaseCollection>, observers: BaseCollection>, collection: class-string>, builder: class-string>, resource: null|class-string} + * + * @throws \Hypervel\Container\BindingResolutionException + */ + public function inspect(string $model, ?string $connection = null): array + { + $class = $this->qualifyModel($model); + + /** @var \Hypervel\Database\Eloquent\Model $model */ + $model = $this->app->make($class); + + if ($connection !== null) { + $model->setConnection($connection); + } + + // @phpstan-ignore return.type (events/observers Collection types cascade from their method limitations) + return [ + 'class' => get_class($model), + 'database' => $model->getConnection()->getName(), + 'table' => $model->getConnection()->getTablePrefix() . $model->getTable(), + 'policy' => $this->getPolicy($model), + 'attributes' => $this->getAttributes($model), + 'relations' => $this->getRelations($model), + 'events' => $this->getEvents($model), + 'observers' => $this->getObservers($model), + 'collection' => $this->getCollectedBy($model), + 'builder' => $this->getBuilder($model), + 'resource' => $this->getResource($model), + ]; + } + + /** + * Get the column attributes for the given model. + * + * @return BaseCollection> + */ + protected function getAttributes(Model $model): BaseCollection + { + $connection = $model->getConnection(); + $schema = $connection->getSchemaBuilder(); + $table = $model->getTable(); + $columns = $schema->getColumns($table); + $indexes = $schema->getIndexes($table); + + return (new BaseCollection($columns)) + ->map(fn ($column) => [ + 'name' => $column['name'], + 'type' => $column['type'], + 'increments' => $column['auto_increment'], + 'nullable' => $column['nullable'], + 'default' => $this->getColumnDefault($column, $model), + 'unique' => $this->columnIsUnique($column['name'], $indexes), + 'fillable' => $model->isFillable($column['name']), + 'hidden' => $this->attributeIsHidden($column['name'], $model), + 'appended' => null, + 'cast' => $this->getCastType($column['name'], $model), + ]) + ->merge($this->getVirtualAttributes($model, $columns)); + } + + /** + * Get the virtual (non-column) attributes for the given model. + * + * @param array> $columns + * @return BaseCollection> + */ + protected function getVirtualAttributes(Model $model, array $columns): BaseCollection + { + $class = new ReflectionClass($model); + + return (new BaseCollection($class->getMethods())) + ->reject( + fn (ReflectionMethod $method) => $method->isStatic() + || $method->isAbstract() + || $method->getDeclaringClass()->getName() === Model::class + ) + ->mapWithKeys(function (ReflectionMethod $method) use ($model) { + if (preg_match('/^get(.+)Attribute$/', $method->getName(), $matches) === 1) { + return [Str::snake($matches[1]) => 'accessor']; + } + if ($model->hasAttributeMutator($method->getName())) { + return [Str::snake($method->getName()) => 'attribute']; + } + return []; + }) + ->reject(fn ($cast, $name) => (new BaseCollection($columns))->contains('name', $name)) + ->map(fn ($cast, $name) => [ + 'name' => $name, + 'type' => null, + 'increments' => false, + 'nullable' => null, + 'default' => null, + 'unique' => null, + 'fillable' => $model->isFillable($name), + 'hidden' => $this->attributeIsHidden($name, $model), + 'appended' => $model->hasAppended($name), + 'cast' => $cast, + ]) + ->values(); + } + + /** + * Get the relations from the given model. + * + * @return BaseCollection> + */ + protected function getRelations(Model $model): BaseCollection + { + return (new BaseCollection(get_class_methods($model))) + ->map(fn ($method) => new ReflectionMethod($model, $method)) + ->reject( + fn (ReflectionMethod $method) => $method->isStatic() + || $method->isAbstract() + || $method->getDeclaringClass()->getName() === Model::class + || $method->getNumberOfParameters() > 0 + ) + ->filter(function (ReflectionMethod $method) { + if ($method->getReturnType() instanceof ReflectionNamedType + && is_subclass_of($method->getReturnType()->getName(), Relation::class)) { + return true; + } + + $file = new SplFileObject($method->getFileName()); + $file->seek($method->getStartLine() - 1); + $code = ''; + while ($file->key() < $method->getEndLine()) { + $code .= trim($file->current()); + $file->next(); + } + + return (new BaseCollection($this->relationMethods)) + ->contains(fn ($relationMethod) => str_contains($code, '$this->' . $relationMethod . '(')); + }) + ->map(function (ReflectionMethod $method) use ($model) { + $relation = $method->invoke($model); + + if (! $relation instanceof Relation) { + return null; + } + + return [ + 'name' => $method->getName(), + 'type' => Str::afterLast(get_class($relation), '\\'), + 'related' => get_class($relation->getRelated()), + ]; + }) + ->filter() + ->values(); + } + + /** + * Get the first policy associated with this model. + * + * @return null|class-string + */ + protected function getPolicy(Model $model): ?string + { + $policy = Gate::getPolicyFor($model::class); + + return $policy ? $policy::class : null; + } + + /** + * Get the events that the model dispatches. + * + * @return BaseCollection + */ + protected function getEvents(Model $model): BaseCollection + { + // @phpstan-ignore return.type (values() resets keys to int, PHPStan doesn't track this) + return (new BaseCollection($model->dispatchesEvents())) + ->map(fn (string $class, string $event) => [ + 'event' => $event, + 'class' => $class, + ])->values(); + } + + /** + * Get the observers watching this model. + * + * @return BaseCollection}> + * + * @throws \Hypervel\Container\BindingResolutionException + */ + protected function getObservers(Model $model): BaseCollection + { + $listeners = $this->app->make('events')->getRawListeners(); + + // Get the Eloquent observers for this model... + $listeners = array_filter($listeners, function ($v, $key) use ($model) { + return Str::startsWith($key, 'eloquent.') && Str::endsWith($key, $model::class); + }, ARRAY_FILTER_USE_BOTH); + + // Format listeners Eloquent verb => Observer methods... + $extractVerb = function ($key) { + preg_match('/eloquent\.([a-zA-Z]+): /', $key, $matches); + + return $matches[1] ?? '?'; + }; + + $formatted = []; + + foreach ($listeners as $key => $observerMethods) { + $formatted[] = [ + 'event' => $extractVerb($key), + 'observer' => array_map(fn ($obs) => is_string($obs) ? $obs : 'Closure', $observerMethods), + ]; + } + + return new BaseCollection($formatted); + } + + /** + * Get the collection class being used by the model. + * + * @return class-string> + */ + protected function getCollectedBy(Model $model): string + { + return $model->newCollection()::class; + } + + /** + * Get the builder class being used by the model. + * + * @return class-string> + */ + protected function getBuilder(Model $model): string + { + return $model->newQuery()::class; + } + + /** + * Get the class used for JSON response transforming. + * + * @return null|class-string + */ + protected function getResource(Model $model): ?string + { + return rescue(static fn () => $model->toResource()::class, null, false); + } + + /** + * Qualify the given model class base name. + * + * @return class-string + * + * @see \Hypervel\Console\GeneratorCommand + */ + protected function qualifyModel(string $model): string + { + if (str_contains($model, '\\') && class_exists($model)) { + return $model; + } + + $model = ltrim($model, '\/'); + + $model = str_replace('/', '\\', $model); + + $rootNamespace = $this->app->getNamespace(); + + if (Str::startsWith($model, $rootNamespace)) { + return $model; + } + + return is_dir(app_path('Models')) + ? $rootNamespace . 'Models\\' . $model + : $rootNamespace . $model; + } + + /** + * Get the cast type for the given column. + */ + protected function getCastType(string $column, Model $model): ?string + { + if ($model->hasGetMutator($column) || $model->hasSetMutator($column)) { + return 'accessor'; + } + + if ($model->hasAttributeMutator($column)) { + return 'attribute'; + } + + return $this->getCastsWithDates($model)->get($column) ?? null; + } + + /** + * Get the model casts, including any date casts. + * + * @return BaseCollection + */ + protected function getCastsWithDates(Model $model): BaseCollection + { + // @phpstan-ignore return.type (flip() makes column names the keys, PHPStan doesn't track this) + return (new BaseCollection($model->getDates())) + ->filter() + ->flip() + ->map(fn () => 'datetime') + ->merge($model->getCasts()); + } + + /** + * Determine if the given attribute is hidden. + */ + protected function attributeIsHidden(string $attribute, Model $model): bool + { + if (count($model->getHidden()) > 0) { + return in_array($attribute, $model->getHidden()); + } + + if (count($model->getVisible()) > 0) { + return ! in_array($attribute, $model->getVisible()); + } + + return false; + } + + /** + * Get the default value for the given column. + */ + protected function getColumnDefault(array $column, Model $model): mixed + { + $attributeDefault = $model->getAttributes()[$column['name']] ?? null; + + return enum_value($attributeDefault) ?? $column['default']; + } + + /** + * Determine if the given attribute is unique. + */ + protected function columnIsUnique(string $column, array $indexes): bool + { + return (new BaseCollection($indexes))->contains( + fn ($index) => count($index['columns']) === 1 && $index['columns'][0] === $column && $index['unique'] + ); + } +} diff --git a/src/database/src/Eloquent/ModelNotFoundException.php b/src/database/src/Eloquent/ModelNotFoundException.php new file mode 100755 index 000000000..2dc9af189 --- /dev/null +++ b/src/database/src/Eloquent/ModelNotFoundException.php @@ -0,0 +1,70 @@ + + */ + protected string $model; + + /** + * The affected model IDs. + * + * @var array + */ + protected array $ids = []; + + /** + * Set the affected Eloquent model and instance ids. + * + * @param class-string $model + * @param array|int|string $ids + */ + public function setModel(string $model, array|int|string $ids = []): static + { + $this->model = $model; + $this->ids = Arr::wrap($ids); + + $this->message = "No query results for model [{$model}]"; + + if (count($this->ids) > 0) { + $this->message .= ' ' . implode(', ', $this->ids); + } else { + $this->message .= '.'; + } + + return $this; + } + + /** + * Get the affected Eloquent model. + * + * @return class-string + */ + public function getModel(): string + { + return $this->model; + } + + /** + * Get the affected Eloquent model IDs. + * + * @return array + */ + public function getIds(): array + { + return $this->ids; + } +} diff --git a/src/database/src/Eloquent/PendingHasThroughRelationship.php b/src/database/src/Eloquent/PendingHasThroughRelationship.php new file mode 100644 index 000000000..ff5db9477 --- /dev/null +++ b/src/database/src/Eloquent/PendingHasThroughRelationship.php @@ -0,0 +1,117 @@ + + */ +class PendingHasThroughRelationship +{ + /** + * The root model that the relationship exists on. + * + * @var TDeclaringModel + */ + protected Model $rootModel; + + /** + * The local relationship. + * + * @var TLocalRelationship + */ + protected HasOneOrMany $localRelationship; + + /** + * Create a pending has-many-through or has-one-through relationship. + * + * @param TDeclaringModel $rootModel + * @param TLocalRelationship $localRelationship + */ + public function __construct(Model $rootModel, HasOneOrMany $localRelationship) + { + $this->rootModel = $rootModel; + $this->localRelationship = $localRelationship; + } + + /** + * Define the distant relationship that this model has. + * + * @template TRelatedModel of \Hypervel\Database\Eloquent\Model + * + * @param (callable(TIntermediateModel): (\Hypervel\Database\Eloquent\Relations\HasMany|\Hypervel\Database\Eloquent\Relations\HasOne|\Hypervel\Database\Eloquent\Relations\MorphOneOrMany))|string $callback + * @return ( + * $callback is string + * ? \Hypervel\Database\Eloquent\Relations\HasManyThrough<\Hypervel\Database\Eloquent\Model, TIntermediateModel, TDeclaringModel>|\Hypervel\Database\Eloquent\Relations\HasOneThrough<\Hypervel\Database\Eloquent\Model, TIntermediateModel, TDeclaringModel> + * : ( + * TLocalRelationship is \Hypervel\Database\Eloquent\Relations\HasMany + * ? \Hypervel\Database\Eloquent\Relations\HasManyThrough + * : ( + * $callback is callable(TIntermediateModel): \Hypervel\Database\Eloquent\Relations\HasMany + * ? \Hypervel\Database\Eloquent\Relations\HasManyThrough + * : \Hypervel\Database\Eloquent\Relations\HasOneThrough + * ) + * ) + * ) + */ + public function has(callable|string $callback): mixed + { + if (is_string($callback)) { + $callback = fn () => $this->localRelationship->getRelated()->{$callback}(); + } + + $distantRelation = $callback($this->localRelationship->getRelated()); + + if ($distantRelation instanceof HasMany || $this->localRelationship instanceof HasMany) { + $returnedRelation = $this->rootModel->hasManyThrough( + $distantRelation->getRelated()::class, + $this->localRelationship->getRelated()::class, + $this->localRelationship->getForeignKeyName(), + $distantRelation->getForeignKeyName(), + $this->localRelationship->getLocalKeyName(), + $distantRelation->getLocalKeyName(), + ); + } else { + $returnedRelation = $this->rootModel->hasOneThrough( + $distantRelation->getRelated()::class, + $this->localRelationship->getRelated()::class, + $this->localRelationship->getForeignKeyName(), + $distantRelation->getForeignKeyName(), + $this->localRelationship->getLocalKeyName(), + $distantRelation->getLocalKeyName(), + ); + } + + if ($this->localRelationship instanceof MorphOneOrMany) { + $returnedRelation->where($this->localRelationship->getQualifiedMorphType(), $this->localRelationship->getMorphClass()); + } + + return $returnedRelation; + } + + /** + * Handle dynamic method calls into the model. + */ + public function __call(string $method, array $parameters): mixed + { + if (Str::startsWith($method, 'has')) { + return $this->has((new Stringable($method))->after('has')->lcfirst()->toString()); + } + + throw new BadMethodCallException(sprintf( + 'Call to undefined method %s::%s()', + static::class, + $method + )); + } +} diff --git a/src/database/src/Eloquent/Prunable.php b/src/database/src/Eloquent/Prunable.php new file mode 100644 index 000000000..583257b7c --- /dev/null +++ b/src/database/src/Eloquent/Prunable.php @@ -0,0 +1,75 @@ +prunable() + ->when(static::isSoftDeletable(), function ($query) { + $query->withTrashed(); + })->chunkById($chunkSize, function ($models) use (&$total) { + $models->each(function ($model) use (&$total) { + try { + $model->prune(); + + ++$total; + } catch (Throwable $e) { + $handler = app(ExceptionHandler::class); + + if ($handler) { + $handler->report($e); + } else { + throw $e; + } + } + }); + + event(new ModelsPruned(static::class, $total)); + }); + + return $total; + } + + /** + * Get the prunable model query. + * + * @return Builder + */ + public function prunable(): Builder + { + throw new LogicException('Please implement the prunable method on your model.'); + } + + /** + * Prune the model in the database. + */ + public function prune(): ?bool + { + $this->pruning(); + + return static::isSoftDeletable() + ? $this->forceDelete() + : $this->delete(); + } + + /** + * Prepare the model for pruning. + */ + protected function pruning(): void + { + } +} diff --git a/src/database/src/Eloquent/QueueEntityResolver.php b/src/database/src/Eloquent/QueueEntityResolver.php new file mode 100644 index 000000000..d467d29e3 --- /dev/null +++ b/src/database/src/Eloquent/QueueEntityResolver.php @@ -0,0 +1,27 @@ +find($id); + + if ($instance) { + return $instance; + } + + throw new EntityNotFoundException($type, $id); + } +} diff --git a/src/database/src/Eloquent/RelationNotFoundException.php b/src/database/src/Eloquent/RelationNotFoundException.php new file mode 100644 index 000000000..b432b6137 --- /dev/null +++ b/src/database/src/Eloquent/RelationNotFoundException.php @@ -0,0 +1,39 @@ +model = $class; + $instance->relation = $relation; + + return $instance; + } +} diff --git a/src/database/src/Eloquent/Relations/BelongsTo.php b/src/database/src/Eloquent/Relations/BelongsTo.php new file mode 100644 index 000000000..ca3f3fd91 --- /dev/null +++ b/src/database/src/Eloquent/Relations/BelongsTo.php @@ -0,0 +1,352 @@ + + */ +class BelongsTo extends Relation +{ + use ComparesRelatedModels; + use InteractsWithDictionary; + use SupportsDefaultModels; + + /** + * The child model instance of the relation. + * + * @var TDeclaringModel + */ + protected Model $child; + + /** + * The foreign key of the parent model. + */ + protected string $foreignKey; + + /** + * The associated key on the parent model. + */ + protected ?string $ownerKey; + + /** + * The name of the relationship. + */ + protected string $relationName; + + /** + * Create a new belongs to relationship instance. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $child + */ + public function __construct(Builder $query, Model $child, string $foreignKey, ?string $ownerKey, string $relationName) + { + $this->ownerKey = $ownerKey; + $this->relationName = $relationName; + $this->foreignKey = $foreignKey; + + // In the underlying base relationship class, this variable is referred to as + // the "parent" since most relationships are not inversed. But, since this + // one is we will create a "child" variable for much better readability. + $this->child = $child; + + parent::__construct($query, $child); + } + + public function getResults() + { + if (is_null($this->getForeignKeyFrom($this->child))) { + return $this->getDefaultFor($this->parent); + } + + return $this->query->first() ?: $this->getDefaultFor($this->parent); + } + + /** + * Set the base constraints on the relation query. + */ + public function addConstraints(): void + { + if (static::shouldAddConstraints()) { + // For belongs to relationships, which are essentially the inverse of has one + // or has many relationships, we need to actually query on the primary key + // of the related models matching on the foreign key that's on a parent. + $key = $this->getQualifiedOwnerKeyName(); + + $this->query->where($key, '=', $this->getForeignKeyFrom($this->child)); + } + } + + public function addEagerConstraints(array $models): void + { + // We'll grab the primary key name of the related models since it could be set to + // a non-standard name and not "id". We will then construct the constraint for + // our eagerly loading query so it returns the proper models from execution. + $key = $this->getQualifiedOwnerKeyName(); + + $whereIn = $this->whereInMethod($this->related, $this->ownerKey); + + $this->whereInEager($whereIn, $key, $this->getEagerModelKeys($models)); + } + + /** + * Gather the keys from an array of related models. + * + * @param array $models + */ + protected function getEagerModelKeys(array $models): array + { + $keys = []; + + // First we need to gather all of the keys from the parent models so we know what + // to query for via the eager loading query. We will add them to an array then + // execute a "where in" statement to gather up all of those related records. + foreach ($models as $model) { + if (! is_null($value = $this->getForeignKeyFrom($model))) { + $keys[] = $value; + } + } + + sort($keys); + + return array_values(array_unique($keys)); + } + + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, $this->getDefaultFor($model)); + } + + return $models; + } + + public function match(array $models, EloquentCollection $results, string $relation): array + { + // First we will get to build a dictionary of the child models by their primary + // key of the relationship, then we can easily match the children back onto + // the parents using that dictionary and the primary key of the children. + $dictionary = []; + + foreach ($results as $result) { + $attribute = $this->getDictionaryKey($this->getRelatedKeyFrom($result)); + + $dictionary[$attribute] = $result; + } + + // Once we have the dictionary constructed, we can loop through all the parents + // and match back onto their children using these keys of the dictionary and + // the primary key of the children to map them onto the correct instances. + foreach ($models as $model) { + $attribute = $this->getDictionaryKey($this->getForeignKeyFrom($model)); + + if (isset($dictionary[$attribute ?? ''])) { + $model->setRelation($relation, $dictionary[$attribute ?? '']); + } + } + + return $models; + } + + /** + * Associate the model instance to the given parent. + * + * @param null|int|string|TRelatedModel $model + * @return TDeclaringModel + */ + public function associate(Model|int|string|null $model): Model + { + $ownerKey = $model instanceof Model ? $model->getAttribute($this->ownerKey) : $model; + + $this->child->setAttribute($this->foreignKey, $ownerKey); + + if ($model instanceof Model) { + $this->child->setRelation($this->relationName, $model); + } else { + $this->child->unsetRelation($this->relationName); + } + + return $this->child; + } + + /** + * Dissociate previously associated model from the given parent. + * + * @return TDeclaringModel + */ + public function dissociate(): Model + { + $this->child->setAttribute($this->foreignKey, null); + + return $this->child->setRelation($this->relationName, null); + } + + /** + * Alias of "dissociate" method. + * + * @return TDeclaringModel + */ + public function disassociate(): Model + { + return $this->dissociate(); + } + + /** + * Touch all of the related models for the relationship. + */ + public function touch(): void + { + if (! is_null($this->getParentKey())) { + parent::touch(); + } + } + + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + if ($parentQuery->getQuery()->from == $query->getQuery()->from) { + return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); + } + + return $query->select($columns)->whereColumn( + $this->getQualifiedForeignKeyName(), + '=', + $query->qualifyColumn($this->ownerKey) + ); + } + + /** + * Add the constraints for a relationship query on the same table. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param \Hypervel\Database\Eloquent\Builder $parentQuery + * @return \Hypervel\Database\Eloquent\Builder + */ + public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + $query->select($columns)->from( + $query->getModel()->getTable() . ' as ' . $hash = $this->getRelationCountHash() + ); + + $query->getModel()->setTable($hash); + + return $query->whereColumn( + $hash . '.' . $this->ownerKey, + '=', + $this->getQualifiedForeignKeyName() + ); + } + + /** + * Determine if the related model has an auto-incrementing ID. + */ + protected function relationHasIncrementingId(): bool + { + return $this->related->getIncrementing() + && in_array($this->related->getKeyType(), ['int', 'integer']); + } + + /** + * Make a new related instance for the given model. + * + * @param TDeclaringModel $parent + * @return TRelatedModel + */ + protected function newRelatedInstanceFor(Model $parent): Model + { + return $this->related->newInstance(); + } + + /** + * Get the child of the relationship. + * + * @return TDeclaringModel + */ + public function getChild(): Model + { + return $this->child; + } + + /** + * Get the foreign key of the relationship. + */ + public function getForeignKeyName(): string + { + return $this->foreignKey; + } + + /** + * Get the fully qualified foreign key of the relationship. + */ + public function getQualifiedForeignKeyName(): string + { + return $this->child->qualifyColumn($this->foreignKey); + } + + /** + * Get the key value of the child's foreign key. + */ + public function getParentKey(): mixed + { + return $this->getForeignKeyFrom($this->child); + } + + /** + * Get the associated key of the relationship. + */ + public function getOwnerKeyName(): ?string + { + return $this->ownerKey; + } + + /** + * Get the fully qualified associated key of the relationship. + */ + public function getQualifiedOwnerKeyName(): string + { + return $this->related->qualifyColumn($this->ownerKey); + } + + /** + * Get the value of the model's foreign key. + * + * @param TRelatedModel $model + */ + protected function getRelatedKeyFrom(Model $model): mixed + { + return $model->{$this->ownerKey}; + } + + /** + * Get the value of the model's foreign key. + * + * @param TDeclaringModel $model + */ + protected function getForeignKeyFrom(Model $model): mixed + { + $foreignKey = $model->{$this->foreignKey}; + + return enum_value($foreignKey); + } + + /** + * Get the name of the relationship. + */ + public function getRelationName(): string + { + return $this->relationName; + } +} diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php new file mode 100644 index 000000000..0de2cae5e --- /dev/null +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -0,0 +1,1489 @@ +> + * + * @todo use TAccessor when PHPStan bug is fixed: https://github.com/phpstan/phpstan/issues/12756 + */ +class BelongsToMany extends Relation +{ + use InteractsWithDictionary; + use InteractsWithPivotTable; + + /** + * The intermediate table for the relation. + */ + protected string $table; + + /** + * The foreign key of the parent model. + */ + protected string $foreignPivotKey; + + /** + * The associated key of the relation. + */ + protected string $relatedPivotKey; + + /** + * The key name of the parent model. + */ + protected string $parentKey; + + /** + * The key name of the related model. + */ + protected string $relatedKey; + + /** + * The "name" of the relationship. + */ + protected ?string $relationName; + + /** + * The pivot table columns to retrieve. + * + * @var array<\Hypervel\Contracts\Database\Query\Expression|string> + */ + protected array $pivotColumns = []; + + /** + * Any pivot table restrictions for where clauses. + */ + protected array $pivotWheres = []; + + /** + * Any pivot table restrictions for whereIn clauses. + */ + protected array $pivotWhereIns = []; + + /** + * Any pivot table restrictions for whereNull clauses. + */ + protected array $pivotWhereNulls = []; + + /** + * The default values for the pivot columns. + */ + protected array $pivotValues = []; + + /** + * Indicates if timestamps are available on the pivot table. + */ + public bool $withTimestamps = false; + + /** + * The custom pivot table column for the created_at timestamp. + */ + protected ?string $pivotCreatedAt = null; + + /** + * The custom pivot table column for the updated_at timestamp. + */ + protected ?string $pivotUpdatedAt = null; + + /** + * The class name of the custom pivot model to use for the relationship. + * + * @var null|class-string + */ + protected ?string $using = null; + + /** + * The name of the accessor to use for the "pivot" relationship. + * + * @var TAccessor + */ + protected string $accessor = 'pivot'; + + /** + * Create a new belongs to many relationship instance. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @param class-string|string $table + */ + public function __construct( + Builder $query, + Model $parent, + string $table, + string $foreignPivotKey, + string $relatedPivotKey, + string $parentKey, + string $relatedKey, + ?string $relationName = null, + ) { + $this->parentKey = $parentKey; + $this->relatedKey = $relatedKey; + $this->relationName = $relationName; + $this->relatedPivotKey = $relatedPivotKey; + $this->foreignPivotKey = $foreignPivotKey; + $this->table = $this->resolveTableName($table); + + parent::__construct($query, $parent); + } + + /** + * Attempt to resolve the intermediate table name from the given string. + */ + protected function resolveTableName(string $table): string + { + if (! str_contains($table, '\\') || ! class_exists($table)) { + return $table; + } + + $model = new $table(); + + if (! $model instanceof Model) { + return $table; + } + + if (in_array(AsPivot::class, class_uses_recursive($model))) { + $this->using($table); + } + + return $model->getTable(); + } + + /** + * Set the base constraints on the relation query. + */ + public function addConstraints(): void + { + $this->performJoin(); + + if (static::shouldAddConstraints()) { + $this->addWhereConstraints(); + } + } + + /** + * Set the join clause for the relation query. + * + * @param null|\Hypervel\Database\Eloquent\Builder $query + * @return $this + */ + protected function performJoin(?Builder $query = null): static + { + $query = $query ?: $this->query; + + // We need to join to the intermediate table on the related model's primary + // key column with the intermediate table's foreign key for the related + // model instance. Then we can set the "where" for the parent models. + $query->join( + $this->table, + $this->getQualifiedRelatedKeyName(), + '=', + $this->getQualifiedRelatedPivotKeyName() + ); + + return $this; + } + + /** + * Set the where clause for the relation query. + * + * @return $this + */ + protected function addWhereConstraints(): static + { + $this->query->where( + $this->getQualifiedForeignPivotKeyName(), + '=', + $this->parent->{$this->parentKey} + ); + + return $this; + } + + public function addEagerConstraints(array $models): void + { + $whereIn = $this->whereInMethod($this->parent, $this->parentKey); + + $this->whereInEager( + $whereIn, + $this->getQualifiedForeignPivotKeyName(), + $this->getKeys($models, $this->parentKey) + ); + } + + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, $this->related->newCollection()); + } + + return $models; + } + + public function match(array $models, EloquentCollection $results, string $relation): array + { + $dictionary = $this->buildDictionary($results); + + // Once we have an array dictionary of child objects we can easily match the + // children back to their parent using the dictionary and the keys on the + // parent models. Then we should return these hydrated models back out. + foreach ($models as $model) { + $key = $this->getDictionaryKey($model->{$this->parentKey}); + + if (isset($dictionary[$key])) { + $model->setRelation( + $relation, + $this->related->newCollection($dictionary[$key]) + ); + } + } + + return $models; + } + + /** + * Build model dictionary keyed by the relation's foreign key. + * + * @param \Hypervel\Database\Eloquent\Collection $results + * @return array> + */ + protected function buildDictionary(EloquentCollection $results): array + { + // First we'll build a dictionary of child models keyed by the foreign key + // of the relation so that we will easily and quickly match them to the + // parents without having a possibly slow inner loop for every model. + $dictionary = []; + + foreach ($results as $result) { + $value = $this->getDictionaryKey($result->{$this->accessor}->{$this->foreignPivotKey}); + + $dictionary[$value][] = $result; + } + + return $dictionary; + } + + /** + * Get the class being used for pivot models. + * + * @return class-string + */ + public function getPivotClass(): string + { + return $this->using ?? Pivot::class; + } + + /** + * Specify the custom pivot model to use for the relationship. + * + * @template TNewPivotModel of \Hypervel\Database\Eloquent\Relations\Pivot + * + * @param class-string $class + * @return $this + * + * @phpstan-this-out static + */ + public function using(string $class): static + { + $this->using = $class; + + return $this; + } + + /** + * Specify the custom pivot accessor to use for the relationship. + * + * @template TNewAccessor of string + * + * @param TNewAccessor $accessor + * @return $this + * + * @phpstan-this-out static + */ + public function as(string $accessor): static + { + $this->accessor = $accessor; + + return $this; + } + + /** + * Set a where clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function wherePivot(mixed $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static + { + $this->pivotWheres[] = func_get_args(); + + return $this->where($this->qualifyPivotColumn($column), $operator, $value, $boolean); + } + + /** + * Set a "where between" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function wherePivotBetween(mixed $column, array $values, string $boolean = 'and', bool $not = false): static + { + return $this->whereBetween($this->qualifyPivotColumn($column), $values, $boolean, $not); + } + + /** + * Set a "or where between" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function orWherePivotBetween(mixed $column, array $values): static + { + return $this->wherePivotBetween($column, $values, 'or'); + } + + /** + * Set a "where pivot not between" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function wherePivotNotBetween(mixed $column, array $values, string $boolean = 'and'): static + { + return $this->wherePivotBetween($column, $values, $boolean, true); + } + + /** + * Set a "or where not between" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function orWherePivotNotBetween(mixed $column, array $values): static + { + return $this->wherePivotBetween($column, $values, 'or', true); + } + + /** + * Set a "where in" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function wherePivotIn(mixed $column, mixed $values, string $boolean = 'and', bool $not = false): static + { + $this->pivotWhereIns[] = func_get_args(); + + return $this->whereIn($this->qualifyPivotColumn($column), $values, $boolean, $not); + } + + /** + * Set an "or where" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function orWherePivot(mixed $column, mixed $operator = null, mixed $value = null): static + { + return $this->wherePivot($column, $operator, $value, 'or'); + } + + /** + * Set a where clause for a pivot table column. + * + * In addition, new pivot records will receive this value. + * + * @param array|\Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + * + * @throws InvalidArgumentException + */ + public function withPivotValue(mixed $column, mixed $value = null): static + { + if (is_array($column)) { + foreach ($column as $name => $value) { + $this->withPivotValue($name, $value); + } + + return $this; + } + + if (is_null($value)) { + throw new InvalidArgumentException('The provided value may not be null.'); + } + + $this->pivotValues[] = compact('column', 'value'); + + return $this->wherePivot($column, '=', $value); + } + + /** + * Set an "or where in" clause for a pivot table column. + * + * @return $this + */ + public function orWherePivotIn(string $column, mixed $values): static + { + return $this->wherePivotIn($column, $values, 'or'); + } + + /** + * Set a "where not in" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function wherePivotNotIn(mixed $column, mixed $values, string $boolean = 'and'): static + { + return $this->wherePivotIn($column, $values, $boolean, true); + } + + /** + * Set an "or where not in" clause for a pivot table column. + * + * @return $this + */ + public function orWherePivotNotIn(string $column, mixed $values): static + { + return $this->wherePivotNotIn($column, $values, 'or'); + } + + /** + * Set a "where null" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function wherePivotNull(mixed $column, string $boolean = 'and', bool $not = false): static + { + $this->pivotWhereNulls[] = func_get_args(); + + return $this->whereNull($this->qualifyPivotColumn($column), $boolean, $not); + } + + /** + * Set a "where not null" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function wherePivotNotNull(mixed $column, string $boolean = 'and'): static + { + return $this->wherePivotNull($column, $boolean, true); + } + + /** + * Set a "or where null" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function orWherePivotNull(mixed $column, bool $not = false): static + { + return $this->wherePivotNull($column, 'or', $not); + } + + /** + * Set a "or where not null" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function orWherePivotNotNull(mixed $column): static + { + return $this->orWherePivotNull($column, true); + } + + /** + * Add an "order by" clause for a pivot table column. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function orderByPivot(mixed $column, string $direction = 'asc'): static + { + return $this->orderBy($this->qualifyPivotColumn($column), $direction); + } + + /** + * Find a related model by its primary key or return a new instance of the related model. + * + * @return ( + * $id is (\Hypervel\Contracts\Support\Arrayable|array) + * ? \Hypervel\Database\Eloquent\Collection + * : TRelatedModel&object{pivot: TPivotModel} + * ) + */ + public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection|Model + { + if (is_null($instance = $this->find($id, $columns))) { + $instance = $this->related->newInstance(); + } + + return $instance; + } + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @return object{pivot: TPivotModel}&TRelatedModel + */ + public function firstOrNew(array $attributes = [], array $values = []): Model + { + if (is_null($instance = $this->related->where($attributes)->first())) { + $instance = $this->related->newInstance(array_merge($attributes, $values)); + } + + return $instance; + } + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @return object{pivot: TPivotModel}&TRelatedModel + */ + public function firstOrCreate(array $attributes = [], array $values = [], array $joining = [], bool $touch = true): Model + { + if (is_null($instance = (clone $this)->where($attributes)->first())) { + if (is_null($instance = $this->related->where($attributes)->first())) { + $instance = $this->createOrFirst($attributes, $values, $joining, $touch); + } else { + try { + $this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch)); + } catch (UniqueConstraintViolationException) { + // Nothing to do, the model was already attached... + } + } + } + + return $instance; + } + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @return object{pivot: TPivotModel}&TRelatedModel + */ + public function createOrFirst(array $attributes = [], array $values = [], array $joining = [], bool $touch = true): Model + { + try { + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values), $joining, $touch)); + } catch (UniqueConstraintViolationException $e) { + // ... + } + + try { + return tap($this->related->where($attributes)->first() ?? throw $e, function ($instance) use ($joining, $touch) { + $this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch)); + }); + } catch (UniqueConstraintViolationException $e) { + return (clone $this)->useWritePdo()->where($attributes)->first() ?? throw $e; + } + } + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @return object{pivot: TPivotModel}&TRelatedModel + */ + public function updateOrCreate(array $attributes, array $values = [], array $joining = [], bool $touch = true): Model + { + return tap($this->firstOrCreate($attributes, $values, $joining, $touch), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values); + + $instance->save(['touch' => false]); + } + }); + } + + /** + * Find a related model by its primary key. + * + * @return ( + * $id is (\Hypervel\Contracts\Support\Arrayable|array) + * ? \Hypervel\Database\Eloquent\Collection + * : (TRelatedModel&object{pivot: TPivotModel})|null + * ) + */ + public function find(mixed $id, array $columns = ['*']): EloquentCollection|Model|null + { + if (! $id instanceof Model && (is_array($id) || $id instanceof Arrayable)) { + return $this->findMany($id, $columns); + } + + return $this->where( + $this->getRelated()->getQualifiedKeyName(), + '=', + $this->parseId($id) + )->first($columns); + } + + /** + * Find a sole related model by its primary key. + * + * @return object{pivot: TPivotModel}&TRelatedModel + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function findSole(mixed $id, array $columns = ['*']): Model + { + return $this->where( + $this->getRelated()->getQualifiedKeyName(), + '=', + $this->parseId($id) + )->sole($columns); + } + + /** + * Find multiple related models by their primary keys. + * + * @param array|\Hypervel\Contracts\Support\Arrayable $ids + * @return \Hypervel\Database\Eloquent\Collection + */ + public function findMany(Arrayable|array $ids, array $columns = ['*']): EloquentCollection + { + $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; + + if (empty($ids)) { + return $this->getRelated()->newCollection(); + } + + return $this->whereKey( + $this->parseIds($ids) + )->get($columns); + } + + /** + * Find a related model by its primary key or throw an exception. + * + * @return ( + * $id is (\Hypervel\Contracts\Support\Arrayable|array) + * ? \Hypervel\Database\Eloquent\Collection + * : TRelatedModel&object{pivot: TPivotModel} + * ) + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + */ + public function findOrFail(mixed $id, array $columns = ['*']): EloquentCollection|Model + { + $result = $this->find($id, $columns); + + $id = $id instanceof Arrayable ? $id->toArray() : $id; + + if (is_array($id)) { + if (count($result) === count(array_unique($id))) { + return $result; + } + } elseif (! is_null($result)) { + return $result; + } + + throw (new ModelNotFoundException())->setModel(get_class($this->related), $id); + } + + /** + * Find a related model by its primary key or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|string $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is (\Hypervel\Contracts\Support\Arrayable|array) + * ? \Hypervel\Database\Eloquent\Collection|TValue + * : (TRelatedModel&object{pivot: TPivotModel})|TValue + * ) + */ + public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closure $callback = null): mixed + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + $result = $this->find($id, $columns); + + $id = $id instanceof Arrayable ? $id->toArray() : $id; + + if (is_array($id)) { + if (count($result) === count(array_unique($id))) { + return $result; + } + } elseif (! is_null($result)) { + return $result; + } + + return $callback(); + } + + /** + * Add a basic where clause to the query, and return the first result. + * + * @return null|(object{pivot: TPivotModel}&TRelatedModel) + */ + public function firstWhere(Closure|string|array $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model + { + return $this->where($column, $operator, $value, $boolean)->first(); + } + + /** + * Execute the query and get the first result. + * + * @return null|(object{pivot: TPivotModel}&TRelatedModel) + */ + public function first(array $columns = ['*']): ?Model + { + $results = $this->limit(1)->get($columns); + + return count($results) > 0 ? $results->first() : null; + } + + /** + * Execute the query and get the first result or throw an exception. + * + * @return object{pivot: TPivotModel}&TRelatedModel + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + */ + public function firstOrFail(array $columns = ['*']): Model + { + if (! is_null($model = $this->first($columns))) { + return $model; + } + + throw (new ModelNotFoundException())->setModel(get_class($this->related)); + } + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list $columns + * @param null|(Closure(): TValue) $callback + * @return (object{pivot: TPivotModel}&TRelatedModel)|TValue + */ + public function firstOr(Closure|array $columns = ['*'], ?Closure $callback = null): mixed + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->first($columns))) { + return $model; + } + + return $callback(); + } + + public function getResults() + { + return ! is_null($this->parent->{$this->parentKey}) + ? $this->get() + : $this->related->newCollection(); + } + + public function get(array $columns = ['*']): EloquentCollection + { + // First we'll add the proper select columns onto the query so it is run with + // the proper columns. Then, we will get the results and hydrate our pivot + // models with the result of those columns as a separate model relation. + $builder = $this->query->applyScopes(); + + $columns = $builder->getQuery()->columns ? [] : $columns; + + // @phpstan-ignore method.notFound (addSelect returns Eloquent\Builder, not Query\Builder) + $models = $builder->addSelect( + $this->shouldSelect($columns) + )->getModels(); + + $this->hydratePivotRelation($models); + + // If we actually found models we will also eager load any relationships that + // have been specified as needing to be eager loaded. This will solve the + // n + 1 query problem for the developer and also increase performance. + if (count($models) > 0) { + $models = $builder->eagerLoadRelations($models); + } + + return $this->query->applyAfterQueryCallbacks( + $this->related->newCollection($models) + ); + } + + /** + * Get the select columns for the relation query. + */ + protected function shouldSelect(array $columns = ['*']): array + { + if ($columns == ['*']) { + $columns = [$this->related->qualifyColumn('*')]; + } + + return array_merge($columns, $this->aliasedPivotColumns()); + } + + /** + * Get the pivot columns for the relation. + * + * "pivot_" is prefixed at each column for easy removal later. + */ + protected function aliasedPivotColumns(): array + { + return (new BaseCollection([ + $this->foreignPivotKey, + $this->relatedPivotKey, + ...$this->pivotColumns, + ])) + ->map(fn ($column) => $this->qualifyPivotColumn($column) . ' as pivot_' . $column) + ->unique() + ->all(); + } + + /** + * Get a paginator for the "select" statement. + * + * @return \Hypervel\Pagination\LengthAwarePaginator + */ + public function paginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null): mixed + { + $this->query->addSelect($this->shouldSelect($columns)); + + return tap($this->query->paginate($perPage, $columns, $pageName, $page), function ($paginator) { + $this->hydratePivotRelation($paginator->items()); + }); + } + + /** + * Paginate the given query into a simple paginator. + * + * @return \Hypervel\Contracts\Pagination\Paginator + */ + public function simplePaginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null): mixed + { + $this->query->addSelect($this->shouldSelect($columns)); + + return tap($this->query->simplePaginate($perPage, $columns, $pageName, $page), function ($paginator) { + $this->hydratePivotRelation($paginator->items()); + }); + } + + /** + * Paginate the given query into a cursor paginator. + * + * @return \Hypervel\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate(?int $perPage = null, array $columns = ['*'], string $cursorName = 'cursor', ?string $cursor = null): mixed + { + $this->query->addSelect($this->shouldSelect($columns)); + + return tap($this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor), function ($paginator) { + $this->hydratePivotRelation($paginator->items()); + }); + } + + /** + * Chunk the results of the query. + */ + public function chunk(int $count, callable $callback): bool + { + return $this->prepareQueryBuilder()->chunk($count, function ($results, $page) use ($callback) { + $this->hydratePivotRelation($results->all()); + + return $callback($results, $page); + }); + } + + /** + * Chunk the results of a query by comparing numeric IDs. + */ + public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool + { + return $this->orderedChunkById($count, $callback, $column, $alias); + } + + /** + * Chunk the results of a query by comparing IDs in descending order. + */ + public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool + { + return $this->orderedChunkById($count, $callback, $column, $alias, descending: true); + } + + /** + * Execute a callback over each item while chunking by ID. + */ + public function eachById(callable $callback, int $count = 1000, ?string $column = null, ?string $alias = null): bool + { + return $this->chunkById($count, function ($results, $page) use ($callback, $count) { + foreach ($results as $key => $value) { + if ($callback($value, (($page - 1) * $count) + $key) === false) { + return false; + } + } + }, $column, $alias); + } + + /** + * Chunk the results of a query by comparing IDs in a given order. + */ + public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, bool $descending = false): bool + { + $column ??= $this->getRelated()->qualifyColumn( + $this->getRelatedKeyName() + ); + + $alias ??= $this->getRelatedKeyName(); + + return $this->prepareQueryBuilder()->orderedChunkById($count, function ($results, $page) use ($callback) { + $this->hydratePivotRelation($results->all()); + + return $callback($results, $page); + }, $column, $alias, $descending); + } + + /** + * Execute a callback over each item while chunking. + */ + public function each(callable $callback, int $count = 1000): bool + { + return $this->chunk($count, function ($results) use ($callback) { + foreach ($results as $key => $value) { + if ($callback($value, $key) === false) { + return false; + } + } + }); + } + + /** + * Query lazily, by chunks of the given size. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazy(int $chunkSize = 1000): mixed + { + return $this->prepareQueryBuilder()->lazy($chunkSize)->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): mixed + { + $column ??= $this->getRelated()->qualifyColumn( + $this->getRelatedKeyName() + ); + + $alias ??= $this->getRelatedKeyName(); + + return $this->prepareQueryBuilder()->lazyById($chunkSize, $column, $alias)->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs in descending order. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): mixed + { + $column ??= $this->getRelated()->qualifyColumn( + $this->getRelatedKeyName() + ); + + $alias ??= $this->getRelatedKeyName(); + + return $this->prepareQueryBuilder()->lazyByIdDesc($chunkSize, $column, $alias)->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + + /** + * Get a lazy collection for the given query. + * + * @return \Hypervel\Support\LazyCollection + */ + public function cursor(): mixed + { + return $this->prepareQueryBuilder()->cursor()->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + + /** + * Prepare the query builder for query execution. + * + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function prepareQueryBuilder(): Builder + { + return $this->query->addSelect($this->shouldSelect()); + } + + /** + * Hydrate the pivot table relationship on the models. + * + * @param array $models + */ + protected function hydratePivotRelation(array $models): void + { + // To hydrate the pivot relationship, we will just gather the pivot attributes + // and create a new Pivot model, which is basically a dynamic model that we + // will set the attributes, table, and connections on it so it will work. + foreach ($models as $model) { + $model->setRelation($this->accessor, $this->newExistingPivot( + $this->migratePivotAttributes($model) + )); + } + } + + /** + * Get the pivot attributes from a model. + * + * @param TRelatedModel $model + */ + protected function migratePivotAttributes(Model $model): array + { + $values = []; + + foreach ($model->getAttributes() as $key => $value) { + // To get the pivots attributes we will just take any of the attributes which + // begin with "pivot_" and add those to this arrays, as well as unsetting + // them from the parent's models since they exist in a different table. + if (str_starts_with($key, 'pivot_')) { + $values[substr($key, 6)] = $value; + + unset($model->{$key}); + } + } + + return $values; + } + + /** + * If we're touching the parent model, touch. + */ + public function touchIfTouching(): void + { + if ($this->touchingParent()) { + $this->getParent()->touch(); + } + + if ($this->getParent()->touches($this->relationName)) { + $this->touch(); + } + } + + /** + * Determine if we should touch the parent on sync. + */ + protected function touchingParent(): bool + { + return $this->getRelated()->touches($this->guessInverseRelation()); + } + + /** + * Attempt to guess the name of the inverse of the relation. + */ + protected function guessInverseRelation(): string + { + return StrCache::camel(StrCache::pluralStudly(class_basename($this->getParent()))); + } + + /** + * Touch all of the related models for the relationship. + * + * E.g.: Touch all roles associated with this user. + */ + public function touch(): void + { + if ($this->related->isIgnoringTouch()) { + return; + } + + $columns = [ + $this->related->getUpdatedAtColumn() => $this->related->freshTimestampString(), + ]; + + // If we actually have IDs for the relation, we will run the query to update all + // the related model's timestamps, to make sure these all reflect the changes + // to the parent models. This will help us keep any caching synced up here. + if (count($ids = $this->allRelatedIds()) > 0) { + $this->getRelated()->newQueryWithoutRelationships()->whereKey($ids)->update($columns); + } + } + + /** + * Get all of the IDs for the related models. + * + * @return \Hypervel\Support\Collection + */ + public function allRelatedIds(): BaseCollection + { + return $this->newPivotQuery()->pluck($this->relatedPivotKey); + } + + /** + * Save a new model and attach it to the parent model. + * + * @param TRelatedModel $model + * @return object{pivot: TPivotModel}&TRelatedModel + */ + public function save(Model $model, array $pivotAttributes = [], bool $touch = true): Model + { + $model->save(['touch' => false]); + + $this->attach($model, $pivotAttributes, $touch); + + return $model; + } + + /** + * Save a new model without raising any events and attach it to the parent model. + * + * @param TRelatedModel $model + * @return object{pivot: TPivotModel}&TRelatedModel + */ + public function saveQuietly(Model $model, array $pivotAttributes = [], bool $touch = true): Model + { + return Model::withoutEvents(function () use ($model, $pivotAttributes, $touch) { + return $this->save($model, $pivotAttributes, $touch); + }); + } + + /** + * Save an array of new models and attach them to the parent model. + * + * @template TContainer of \Hypervel\Support\Collection|array + * + * @param TContainer $models + * @return TContainer + */ + public function saveMany(iterable $models, array $pivotAttributes = []): iterable + { + foreach ($models as $key => $model) { + $this->save($model, (array) ($pivotAttributes[$key] ?? []), false); + } + + $this->touchIfTouching(); + + return $models; + } + + /** + * Save an array of new models without raising any events and attach them to the parent model. + * + * @template TContainer of \Hypervel\Support\Collection|array + * + * @param TContainer $models + * @return TContainer + */ + public function saveManyQuietly(iterable $models, array $pivotAttributes = []): iterable + { + return Model::withoutEvents(function () use ($models, $pivotAttributes) { + return $this->saveMany($models, $pivotAttributes); + }); + } + + /** + * Create a new instance of the related model. + * + * @return object{pivot: TPivotModel}&TRelatedModel + */ + public function create(array $attributes = [], array $joining = [], bool $touch = true): Model + { + $attributes = array_merge($this->getQuery()->pendingAttributes, $attributes); + + $instance = $this->related->newInstance($attributes); + + // Once we save the related model, we need to attach it to the base model via + // through intermediate table so we'll use the existing "attach" method to + // accomplish this which will insert the record and any more attributes. + $instance->save(['touch' => false]); + + $this->attach($instance, $joining, $touch); + + return $instance; + } + + /** + * Create an array of new instances of the related models. + * + * @return array + */ + public function createMany(iterable $records, array $joinings = []): array + { + $instances = []; + + foreach ($records as $key => $record) { + $instances[] = $this->create($record, (array) ($joinings[$key] ?? []), false); + } + + $this->touchIfTouching(); + + return $instances; + } + + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + if ($parentQuery->getQuery()->from == $query->getQuery()->from) { + return $this->getRelationExistenceQueryForSelfJoin($query, $parentQuery, $columns); + } + + $this->performJoin($query); + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + /** + * Add the constraints for a relationship query on the same table. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param \Hypervel\Database\Eloquent\Builder $parentQuery + * @return \Hypervel\Database\Eloquent\Builder + */ + public function getRelationExistenceQueryForSelfJoin(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + $query->select($columns); + + $query->from($this->related->getTable() . ' as ' . $hash = $this->getRelationCountHash()); + + $this->related->setTable($hash); + + $this->performJoin($query); + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + /** + * Alias to set the "limit" value of the query. + * + * @return $this + */ + public function take(int $value): static + { + return $this->limit($value); + } + + /** + * Set the "limit" value of the query. + * + * @return $this + */ + public function limit(int $value): static + { + if ($this->parent->exists) { + $this->query->limit($value); + } else { + $column = $this->getExistenceCompareKey(); + + $grammar = $this->query->getQuery()->getGrammar(); + + if ($grammar instanceof MySqlGrammar && $grammar->useLegacyGroupLimit($this->query->getQuery())) { + $column = 'pivot_' . last(explode('.', $column)); + } + + $this->query->groupLimit($value, $column); + } + + return $this; + } + + /** + * Get the key for comparing against the parent key in "has" query. + */ + public function getExistenceCompareKey(): string + { + return $this->getQualifiedForeignPivotKeyName(); + } + + /** + * Specify that the pivot table has creation and update timestamps. + * + * @return $this + */ + public function withTimestamps(string|false|null $createdAt = null, string|false|null $updatedAt = null): static + { + $this->pivotCreatedAt = $createdAt !== false ? $createdAt : null; + $this->pivotUpdatedAt = $updatedAt !== false ? $updatedAt : null; + + $pivots = array_filter([ + $createdAt !== false ? $this->createdAt() : null, + $updatedAt !== false ? $this->updatedAt() : null, + ]); + + $this->withTimestamps = ! empty($pivots); + + return $this->withTimestamps ? $this->withPivot($pivots) : $this; + } + + /** + * Get the name of the "created at" column. + */ + public function createdAt(): string + { + return $this->pivotCreatedAt ?? $this->parent->getCreatedAtColumn() ?? Model::CREATED_AT; + } + + /** + * Get the name of the "updated at" column. + */ + public function updatedAt(): string + { + return $this->pivotUpdatedAt ?? $this->parent->getUpdatedAtColumn() ?? Model::UPDATED_AT; + } + + /** + * Get the foreign key for the relation. + */ + public function getForeignPivotKeyName(): string + { + return $this->foreignPivotKey; + } + + /** + * Get the fully qualified foreign key for the relation. + */ + public function getQualifiedForeignPivotKeyName(): string + { + return $this->qualifyPivotColumn($this->foreignPivotKey); + } + + /** + * Get the "related key" for the relation. + */ + public function getRelatedPivotKeyName(): string + { + return $this->relatedPivotKey; + } + + /** + * Get the fully qualified "related key" for the relation. + */ + public function getQualifiedRelatedPivotKeyName(): string + { + return $this->qualifyPivotColumn($this->relatedPivotKey); + } + + /** + * Get the parent key for the relationship. + */ + public function getParentKeyName(): string + { + return $this->parentKey; + } + + /** + * Get the fully qualified parent key name for the relation. + */ + public function getQualifiedParentKeyName(): string + { + return $this->parent->qualifyColumn($this->parentKey); + } + + /** + * Get the related key for the relationship. + */ + public function getRelatedKeyName(): string + { + return $this->relatedKey; + } + + /** + * Get the fully qualified related key name for the relation. + */ + public function getQualifiedRelatedKeyName(): string + { + return $this->related->qualifyColumn($this->relatedKey); + } + + /** + * Get the intermediate table for the relationship. + */ + public function getTable(): string + { + return $this->table; + } + + /** + * Get the relationship name for the relationship. + */ + public function getRelationName(): ?string + { + return $this->relationName; + } + + /** + * Get the name of the pivot accessor for this relationship. + * + * @return TAccessor + */ + public function getPivotAccessor(): string + { + return $this->accessor; + } + + /** + * Get the pivot columns for this relationship. + */ + public function getPivotColumns(): array + { + return $this->pivotColumns; + } + + /** + * Qualify the given column name by the pivot table. + * + * @param \Hypervel\Contracts\Database\Query\Expression|string $column + * @return \Hypervel\Contracts\Database\Query\Expression|string + */ + public function qualifyPivotColumn(mixed $column): mixed + { + if ($this->query->getQuery()->getGrammar()->isExpression($column)) { + return $column; + } + + return str_contains($column, '.') + ? $column + : $this->table . '.' . $column; + } +} diff --git a/src/database/src/Eloquent/Relations/Concerns/AsPivot.php b/src/database/src/Eloquent/Relations/Concerns/AsPivot.php new file mode 100644 index 000000000..e868ffe95 --- /dev/null +++ b/src/database/src/Eloquent/Relations/Concerns/AsPivot.php @@ -0,0 +1,325 @@ +timestamps = $instance->hasTimestampAttributes($attributes); + + // The pivot model is a "dynamic" model since we will set the tables dynamically + // for the instance. This allows it work for any intermediate tables for the + // many to many relationship that are defined by this developer's classes. + $instance->setConnection($parent->getConnectionName()) + ->setTable($table) + ->forceFill($attributes) + ->syncOriginal(); + + // We store off the parent instance so we will access the timestamp column names + // for the model, since the pivot model timestamps aren't easily configurable + // from the developer's point of view. We can use the parents to get these. + $instance->pivotParent = $parent; + + $instance->exists = $exists; + + return $instance; + } + + /** + * Create a new pivot model from raw values returned from a query. + */ + public static function fromRawAttributes(Model $parent, array $attributes, string $table, bool $exists = false): static + { + $instance = static::fromAttributes($parent, [], $table, $exists); + + $instance->timestamps = $instance->hasTimestampAttributes($attributes); + + $instance->setRawAttributes( + array_merge($instance->getRawOriginal(), $attributes), + $exists + ); + + return $instance; + } + + /** + * Set the keys for a select query. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function setKeysForSelectQuery(Builder $query): Builder + { + if (isset($this->attributes[$this->getKeyName()])) { + return parent::setKeysForSelectQuery($query); + } + + $query->where($this->foreignKey, $this->getOriginal( + $this->foreignKey, + $this->getAttribute($this->foreignKey) + )); + + return $query->where($this->relatedKey, $this->getOriginal( + $this->relatedKey, + $this->getAttribute($this->relatedKey) + )); + } + + /** + * Set the keys for a save update query. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function setKeysForSaveQuery(Builder $query): Builder + { + return $this->setKeysForSelectQuery($query); + } + + /** + * Delete the pivot model record from the database. + * + * Returns affected row count (int) rather than bool|null because pivots + * use query builder deletion with compound keys. + */ + public function delete(): int + { + if (isset($this->attributes[$this->getKeyName()])) { + return (int) parent::delete(); + } + + if ($this->fireModelEvent('deleting') === false) { + return 0; + } + + $this->touchOwners(); + + return tap($this->getDeleteQuery()->delete(), function () { + $this->exists = false; + + $this->fireModelEvent('deleted', false); + }); + } + + /** + * Get the query builder for a delete operation on the pivot. + * + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function getDeleteQuery(): Builder + { + return $this->newQueryWithoutRelationships()->where([ + $this->foreignKey => $this->getOriginal($this->foreignKey, $this->getAttribute($this->foreignKey)), + $this->relatedKey => $this->getOriginal($this->relatedKey, $this->getAttribute($this->relatedKey)), + ]); + } + + /** + * Get the table associated with the model. + */ + public function getTable(): string + { + if (! isset($this->table)) { + $this->setTable(str_replace( + '\\', + '', + StrCache::snake(StrCache::singular(class_basename($this))) + )); + } + + return $this->table; + } + + /** + * Get the foreign key column name. + */ + public function getForeignKey(): string + { + return $this->foreignKey; + } + + /** + * Get the "related key" column name. + */ + public function getRelatedKey(): string + { + return $this->relatedKey; + } + + /** + * Get the "related key" column name. + */ + public function getOtherKey(): string + { + return $this->getRelatedKey(); + } + + /** + * Set the key names for the pivot model instance. + * + * @return $this + */ + public function setPivotKeys(string $foreignKey, string $relatedKey): static + { + $this->foreignKey = $foreignKey; + + $this->relatedKey = $relatedKey; + + return $this; + } + + /** + * Set the related model of the relationship. + * + * @return $this + */ + public function setRelatedModel(?Model $related = null): static + { + $this->pivotRelated = $related; + + return $this; + } + + /** + * Determine if the pivot model or given attributes has timestamp attributes. + */ + public function hasTimestampAttributes(?array $attributes = null): bool + { + return ($createdAt = $this->getCreatedAtColumn()) !== null + && array_key_exists($createdAt, $attributes ?? $this->attributes); + } + + /** + * Get the name of the "created at" column. + */ + public function getCreatedAtColumn(): ?string + { + return $this->pivotParent + ? $this->pivotParent->getCreatedAtColumn() + : parent::getCreatedAtColumn(); + } + + /** + * Get the name of the "updated at" column. + */ + public function getUpdatedAtColumn(): ?string + { + return $this->pivotParent + ? $this->pivotParent->getUpdatedAtColumn() + : parent::getUpdatedAtColumn(); + } + + /** + * Get the queueable identity for the entity. + */ + public function getQueueableId(): mixed + { + if (isset($this->attributes[$this->getKeyName()])) { + return $this->getKey(); + } + + return sprintf( + '%s:%s:%s:%s', + $this->foreignKey, + $this->getAttribute($this->foreignKey), + $this->relatedKey, + $this->getAttribute($this->relatedKey) + ); + } + + /** + * Get a new query to restore one or more models by their queueable IDs. + * + * @return \Hypervel\Database\Eloquent\Builder + */ + public function newQueryForRestoration(array|int|string $ids): Builder + { + if (is_array($ids)) { + return $this->newQueryForCollectionRestoration($ids); + } + + if (! str_contains($ids, ':')) { + return parent::newQueryForRestoration($ids); + } + + $segments = explode(':', $ids); + + return $this->newQueryWithoutScopes() + ->where($segments[0], $segments[1]) + ->where($segments[2], $segments[3]); + } + + /** + * Get a new query to restore multiple models by their queueable IDs. + * + * @param int[]|string[] $ids + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function newQueryForCollectionRestoration(array $ids): Builder + { + $ids = array_values($ids); + + if (! str_contains($ids[0], ':')) { + return parent::newQueryForRestoration($ids); + } + + $query = $this->newQueryWithoutScopes(); + + foreach ($ids as $id) { + $segments = explode(':', $id); + + $query->orWhere(function ($query) use ($segments) { + return $query->where($segments[0], $segments[1]) + ->where($segments[2], $segments[3]); + }); + } + + return $query; + } + + /** + * Unset all the loaded relations for the instance. + * + * @return $this + */ + public function unsetRelations(): static + { + $this->pivotParent = null; + $this->pivotRelated = null; + $this->relations = []; + + return $this; + } +} diff --git a/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php new file mode 100644 index 000000000..2cf9c8f21 --- /dev/null +++ b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php @@ -0,0 +1,296 @@ +|null + */ + protected ?Builder $oneOfManySubQuery = null; + + /** + * Add constraints for inner join subselect for one of many relationships. + * + * @param \Hypervel\Database\Eloquent\Builder<*> $query + */ + abstract public function addOneOfManySubQueryConstraints(Builder $query, ?string $column = null, ?string $aggregate = null): void; + + /** + * Get the columns the determine the relationship groups. + */ + abstract public function getOneOfManySubQuerySelectColumns(): array|string; + + /** + * Add join query constraints for one of many relationships. + */ + abstract public function addOneOfManyJoinSubQueryConstraints(JoinClause $join): void; + + /** + * Indicate that the relation is a single result of a larger one-to-many relationship. + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function ofMany(string|array|null $column = 'id', string|Closure|null $aggregate = 'MAX', ?string $relation = null): static + { + $this->isOneOfMany = true; + + $this->relationName = $relation ?: $this->getDefaultOneOfManyJoinAlias( + $this->guessRelationship() + ); + + $keyName = $this->query->getModel()->getKeyName(); + + $columns = is_string($columns = $column) ? [ + $column => $aggregate, + $keyName => $aggregate, + ] : $column; + + if (! array_key_exists($keyName, $columns)) { + $columns[$keyName] = 'MAX'; + } + + if ($aggregate instanceof Closure) { + $closure = $aggregate; + } + + foreach ($columns as $column => $aggregate) { + if (! in_array(strtolower($aggregate), ['min', 'max'])) { + throw new InvalidArgumentException("Invalid aggregate [{$aggregate}] used within ofMany relation. Available aggregates: MIN, MAX"); + } + + $subQuery = $this->newOneOfManySubQuery( + $this->getOneOfManySubQuerySelectColumns(), + array_merge([$column], $previous['columns'] ?? []), + $aggregate, + ); + + if (isset($previous)) { + $this->addOneOfManyJoinSubQuery( + $subQuery, + $previous['subQuery'], + $previous['columns'], + ); + } + + if (isset($closure)) { + $closure($subQuery); + } + + if (! isset($previous)) { + $this->oneOfManySubQuery = $subQuery; + } + + if (array_key_last($columns) == $column) { + $this->addOneOfManyJoinSubQuery( + $this->query, + $subQuery, + array_merge([$column], $previous['columns'] ?? []), + ); + } + + $previous = [ + 'subQuery' => $subQuery, + 'columns' => array_merge([$column], $previous['columns'] ?? []), + ]; + } + + $this->addConstraints(); + + $columns = $this->query->getQuery()->columns; + + if (is_null($columns) || $columns === ['*']) { + $this->select([$this->qualifyColumn('*')]); + } + + return $this; + } + + /** + * Indicate that the relation is the latest single result of a larger one-to-many relationship. + * + * @return $this + */ + public function latestOfMany(string|array|null $column = 'id', ?string $relation = null): static + { + return $this->ofMany(Collection::wrap($column)->mapWithKeys(function ($column) { + return [$column => 'MAX']; + })->all(), 'MAX', $relation); + } + + /** + * Indicate that the relation is the oldest single result of a larger one-to-many relationship. + * + * @return $this + */ + public function oldestOfMany(string|array|null $column = 'id', ?string $relation = null): static + { + return $this->ofMany(Collection::wrap($column)->mapWithKeys(function ($column) { + return [$column => 'MIN']; + })->all(), 'MIN', $relation); + } + + /** + * Get the default alias for the one of many inner join clause. + */ + protected function getDefaultOneOfManyJoinAlias(string $relation): string + { + return $relation == $this->query->getModel()->getTable() + ? $relation . '_of_many' + : $relation; + } + + /** + * Get a new query for the related model, grouping the query by the given column, often the foreign key of the relationship. + * + * @param null|array $columns + * @return \Hypervel\Database\Eloquent\Builder<*> + */ + protected function newOneOfManySubQuery(string|array $groupBy, ?array $columns = null, ?string $aggregate = null): Builder + { + $subQuery = $this->query->getModel() + ->newQuery() + ->withoutGlobalScopes($this->removedScopes()); + + foreach (Arr::wrap($groupBy) as $group) { + $subQuery->groupBy($this->qualifyRelatedColumn($group)); + } + + if (! is_null($columns)) { + foreach ($columns as $key => $column) { + $aggregatedColumn = $subQuery->getQuery()->grammar->wrap($subQuery->qualifyColumn($column)); + + if ($key === 0) { + $aggregatedColumn = "{$aggregate}({$aggregatedColumn})"; + } else { + $aggregatedColumn = "min({$aggregatedColumn})"; + } + + $subQuery->selectRaw($aggregatedColumn . ' as ' . $subQuery->getQuery()->grammar->wrap($column . '_aggregate')); + } + } + + $this->addOneOfManySubQueryConstraints($subQuery, column: null, aggregate: $aggregate); + + return $subQuery; + } + + /** + * Add the join subquery to the given query on the given column and the relationship's foreign key. + * + * @param \Hypervel\Database\Eloquent\Builder<*> $parent + * @param \Hypervel\Database\Eloquent\Builder<*> $subQuery + * @param array $on + */ + protected function addOneOfManyJoinSubQuery(Builder $parent, Builder $subQuery, array $on): void + { + $parent->beforeQuery(function ($parent) use ($subQuery, $on) { + $subQuery->applyBeforeQueryCallbacks(); + + $parent->joinSub($subQuery, $this->relationName, function ($join) use ($on) { + foreach ($on as $onColumn) { + $join->on($this->qualifySubSelectColumn($onColumn . '_aggregate'), '=', $this->qualifyRelatedColumn($onColumn)); + } + + $this->addOneOfManyJoinSubQueryConstraints($join); + }); + }); + } + + /** + * Merge the relationship query joins to the given query builder. + * + * @param \Hypervel\Database\Eloquent\Builder<*> $query + */ + protected function mergeOneOfManyJoinsTo(Builder $query): void + { + $query->getQuery()->beforeQueryCallbacks = $this->query->getQuery()->beforeQueryCallbacks; + + $query->applyBeforeQueryCallbacks(); + } + + /** + * Get the query builder that will contain the relationship constraints. + * + * @return \Hypervel\Database\Eloquent\Builder<*> + */ + protected function getRelationQuery(): Builder + { + return $this->isOneOfMany() + ? $this->oneOfManySubQuery + : $this->query; + } + + /** + * Get the one of many inner join subselect builder instance. + * + * @return \Hypervel\Database\Eloquent\Builder<*>|null + */ + public function getOneOfManySubQuery(): ?Builder + { + return $this->oneOfManySubQuery; + } + + /** + * Get the qualified column name for the one-of-many relationship using the subselect join query's alias. + */ + public function qualifySubSelectColumn(string $column): string + { + return $this->getRelationName() . '.' . last(explode('.', $column)); + } + + /** + * Qualify related column using the related table name if it is not already qualified. + */ + protected function qualifyRelatedColumn(string $column): string + { + return $this->query->getModel()->qualifyColumn($column); + } + + /** + * Guess the "hasOne" relationship's name via backtrace. + */ + protected function guessRelationship(): string + { + return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; + } + + /** + * Determine whether the relationship is a one-of-many relationship. + */ + public function isOneOfMany(): bool + { + return $this->isOneOfMany; + } + + /** + * Get the name of the relationship. + */ + public function getRelationName(): string + { + return $this->relationName; + } +} diff --git a/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php b/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php new file mode 100644 index 000000000..2d3f73cea --- /dev/null +++ b/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php @@ -0,0 +1,64 @@ +compareKeys($this->getParentKey(), $this->getRelatedKeyFrom($model)) + && $this->related->getTable() === $model->getTable() + && $this->related->getConnectionName() === $model->getConnectionName(); + + if ($match && $this instanceof SupportsPartialRelations && $this->isOneOfMany()) { + return $this->query + ->whereKey($model->getKey()) + ->exists(); + } + + return $match; + } + + /** + * Determine if the model is not the related instance of the relationship. + */ + public function isNot(?Model $model): bool + { + return ! $this->is($model); + } + + /** + * Get the value of the parent model's key. + */ + abstract public function getParentKey(): mixed; + + /** + * Get the value of the model's related key. + */ + abstract protected function getRelatedKeyFrom(Model $model): mixed; + + /** + * Compare the parent key with the related key. + */ + protected function compareKeys(mixed $parentKey, mixed $relatedKey): bool + { + if (empty($parentKey) || empty($relatedKey)) { + return false; + } + + if (is_int($parentKey) || is_int($relatedKey)) { + return (int) $parentKey === (int) $relatedKey; + } + + return $parentKey === $relatedKey; + } +} diff --git a/src/database/src/Eloquent/Relations/Concerns/InteractsWithDictionary.php b/src/database/src/Eloquent/Relations/Concerns/InteractsWithDictionary.php new file mode 100644 index 000000000..c89e01c47 --- /dev/null +++ b/src/database/src/Eloquent/Relations/Concerns/InteractsWithDictionary.php @@ -0,0 +1,35 @@ +__toString(); + } + + if ($attribute instanceof UnitEnum) { + return enum_value($attribute); + } + + throw new InvalidArgumentException('Model attribute value is an object but does not have a __toString method.'); + } + + return $attribute; + } +} diff --git a/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php new file mode 100644 index 000000000..b9987891f --- /dev/null +++ b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -0,0 +1,611 @@ + [], 'detached' => [], + ]; + + $records = $this->formatRecordsList($this->parseIds($ids)); + + // Next, we will determine which IDs should get removed from the join table by + // checking which of the given ID/records is in the list of current records + // and removing all of those rows from this "intermediate" joining table. + $detach = array_values(array_intersect( + $this->newPivotQuery()->pluck($this->relatedPivotKey)->all(), + array_keys($records) + )); + + if (count($detach) > 0) { + $this->detach($detach, false); + + $changes['detached'] = $this->castKeys($detach); + } + + // Finally, for all of the records which were not "detached", we'll attach the + // records into the intermediate table. Then, we will add those attaches to + // this change list and get ready to return these results to the callers. + $attach = array_diff_key($records, array_flip($detach)); + + if (count($attach) > 0) { + $this->attach($attach, [], false); + + $changes['attached'] = array_keys($attach); + } + + // Once we have finished attaching or detaching the records, we will see if we + // have done any attaching or detaching, and if we have we will touch these + // relationships if they are configured to touch on any database updates. + if ($touch && (count($changes['attached']) + || count($changes['detached']))) { + $this->touchIfTouching(); + } + + return $changes; + } + + /** + * Sync the intermediate tables with a list of IDs without detaching. + * + * @return array{attached: array, detached: array, updated: array} + */ + public function syncWithoutDetaching(BaseCollection|Model|array|int|string $ids): array + { + return $this->sync($ids, false); + } + + /** + * Sync the intermediate tables with a list of IDs or collection of models. + * + * @return array{attached: array, detached: array, updated: array} + */ + public function sync(BaseCollection|Model|array|int|string $ids, bool $detaching = true): array + { + $changes = [ + 'attached' => [], 'detached' => [], 'updated' => [], + ]; + + $records = $this->formatRecordsList($this->parseIds($ids)); + + if (empty($records) && ! $detaching) { + return $changes; + } + + // First we need to attach any of the associated models that are not currently + // in this joining table. We'll spin through the given IDs, checking to see + // if they exist in the array of current ones, and if not we will insert. + $current = $this->getCurrentlyAttachedPivots() + ->pluck($this->relatedPivotKey)->all(); + + // Next, we will take the differences of the currents and given IDs and detach + // 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 (count($detach) > 0) { + $this->detach($detach, false); + + $changes['detached'] = $this->castKeys($detach); + } + } + + // Now we are finally ready to attach the new records. Note that we'll disable + // touching until after the entire operation is complete so we don't fire a + // ton of touch operations until we are totally done syncing the records. + $changes = array_merge( + $changes, + $this->attachNew($records, $current, false) + ); + + // Once we have finished attaching or detaching the records, we will see if we + // have done any attaching or detaching, and if we have we will touch these + // relationships if they are configured to touch on any database updates. + if (count($changes['attached']) + || count($changes['updated']) + || count($changes['detached'])) { + $this->touchIfTouching(); + } + + return $changes; + } + + /** + * Sync the intermediate tables with a list of IDs or collection of models with the given pivot values. + * + * @return array{attached: array, detached: array, updated: array} + */ + public function syncWithPivotValues(BaseCollection|Model|array|int|string $ids, array $values, bool $detaching = true): array + { + return $this->sync((new BaseCollection($this->parseIds($ids)))->mapWithKeys(function ($id) use ($values) { + return [$id => $values]; + }), $detaching); + } + + /** + * Format the sync / toggle record list so that it is keyed by ID. + */ + protected function formatRecordsList(array $records): array + { + return (new BaseCollection($records))->mapWithKeys(function ($attributes, $id) { + if (! is_array($attributes)) { + [$id, $attributes] = [$attributes, []]; + } + + if ($id instanceof BackedEnum) { + $id = $id->value; + } + + return [$id => $attributes]; + })->all(); + } + + /** + * Attach all of the records that aren't in the given current records. + */ + protected function attachNew(array $records, array $current, bool $touch = true): array + { + $changes = ['attached' => [], 'updated' => []]; + + foreach ($records as $id => $attributes) { + // If the ID is not in the list of existing pivot IDs, we will insert a new pivot + // record, otherwise, we will just update this existing record on this joining + // table, so that the developers will easily update these records pain free. + if (! in_array($id, $current)) { + $this->attach($id, $attributes, $touch); + + $changes['attached'][] = $this->castKey($id); + } + + // Now we'll try to update an existing pivot record with the attributes that were + // given to the method. If the model is actually updated we will add it to the + // list of updated pivot records so we return them back out to the consumer. + elseif (count($attributes) > 0 + && $this->updateExistingPivot($id, $attributes, $touch)) { + $changes['updated'][] = $this->castKey($id); + } + } + + return $changes; + } + + /** + * Update an existing pivot record on the table. + */ + public function updateExistingPivot(mixed $id, array $attributes, bool $touch = true): int + { + if ($this->using) { + return $this->updateExistingPivotUsingCustomClass($id, $attributes, $touch); + } + + if ($this->hasPivotColumn($this->updatedAt())) { + $attributes = $this->addTimestampsToAttachment($attributes, true); + } + + $updated = $this->newPivotStatementForId($id)->update( + $this->castAttributes($attributes) + ); + + if ($touch) { + $this->touchIfTouching(); + } + + return $updated; + } + + /** + * Update an existing pivot record on the table via a custom class. + */ + protected function updateExistingPivotUsingCustomClass(mixed $id, array $attributes, bool $touch): int + { + $pivot = $this->getCurrentlyAttachedPivotsForIds($id)->first(); + + $updated = $pivot ? $pivot->fill($attributes)->isDirty() : false; + + if ($updated) { + $pivot->save(); + } + + if ($touch) { + $this->touchIfTouching(); + } + + return (int) $updated; + } + + /** + * Attach a model to the parent. + */ + public function attach(mixed $ids, array $attributes = [], bool $touch = true): void + { + if ($this->using) { + $this->attachUsingCustomClass($ids, $attributes); + } else { + // Here we will insert the attachment records into the pivot table. Once we have + // inserted the records, we will touch the relationships if necessary and the + // function will return. We can parse the IDs before inserting the records. + $this->newPivotStatement()->insert($this->formatAttachRecords( + $this->parseIds($ids), + $attributes + )); + } + + if ($touch) { + $this->touchIfTouching(); + } + } + + /** + * Attach a model to the parent using a custom class. + */ + protected function attachUsingCustomClass(mixed $ids, array $attributes): void + { + $records = $this->formatAttachRecords( + $this->parseIds($ids), + $attributes + ); + + foreach ($records as $record) { + $this->newPivot($record, false)->save(); + } + } + + /** + * Create an array of records to insert into the pivot table. + */ + protected function formatAttachRecords(array $ids, array $attributes): array + { + $records = []; + + $hasTimestamps = ($this->hasPivotColumn($this->createdAt()) + || $this->hasPivotColumn($this->updatedAt())); + + // To create the attachment records, we will simply spin through the IDs given + // and create a new record to insert for each ID. Each ID may actually be a + // key in the array, with extra attributes to be placed in other columns. + foreach ($ids as $key => $value) { + $records[] = $this->formatAttachRecord( + $key, + $value, + $attributes, + $hasTimestamps + ); + } + + return $records; + } + + /** + * Create a full attachment record payload. + */ + protected function formatAttachRecord(int|string $key, mixed $value, array $attributes, bool $hasTimestamps): array + { + [$id, $attributes] = $this->extractAttachIdAndAttributes($key, $value, $attributes); + + return array_merge( + $this->baseAttachRecord($id, $hasTimestamps), + $this->castAttributes($attributes) + ); + } + + /** + * Get the attach record ID and extra attributes. + */ + protected function extractAttachIdAndAttributes(mixed $key, mixed $value, array $attributes): array + { + return is_array($value) + ? [$key, array_merge($value, $attributes)] + : [$value, $attributes]; + } + + /** + * Create a new pivot attachment record. + */ + protected function baseAttachRecord(mixed $id, bool $timed): array + { + $record[$this->relatedPivotKey] = $id; + + $record[$this->foreignPivotKey] = $this->parent->{$this->parentKey}; + + // If the record needs to have creation and update timestamps, we will make + // them by calling the parent model's "freshTimestamp" method which will + // provide us with a fresh timestamp in this model's preferred format. + if ($timed) { + $record = $this->addTimestampsToAttachment($record); + } + + foreach ($this->pivotValues as $value) { + $record[$value['column']] = $value['value']; + } + + return $record; + } + + /** + * Set the creation and update timestamps on an attach record. + */ + protected function addTimestampsToAttachment(array $record, bool $exists = false): array + { + $fresh = $this->parent->freshTimestamp(); + + if ($this->using) { + $pivotModel = new $this->using(); + + $fresh = $pivotModel->fromDateTime($fresh); + } + + if (! $exists && $this->hasPivotColumn($this->createdAt())) { + $record[$this->createdAt()] = $fresh; + } + + if ($this->hasPivotColumn($this->updatedAt())) { + $record[$this->updatedAt()] = $fresh; + } + + return $record; + } + + /** + * Determine whether the given column is defined as a pivot column. + */ + public function hasPivotColumn(?string $column): bool + { + return in_array($column, $this->pivotColumns); + } + + /** + * Detach models from the relationship. + */ + public function detach(mixed $ids = null, bool $touch = true): int + { + if ($this->using) { + $results = $this->detachUsingCustomClass($ids); + } else { + $query = $this->newPivotQuery(); + + // If associated IDs were passed to the method we will only delete those + // associations, otherwise all of the association ties will be broken. + // We'll return the numbers of affected rows when we do the deletes. + if (! is_null($ids)) { + $ids = $this->parseIds($ids); + + if (empty($ids)) { + return 0; + } + + $query->whereIn($this->getQualifiedRelatedPivotKeyName(), (array) $ids); + } + + // Once we have all of the conditions set on the statement, we are ready + // to run the delete on the pivot table. Then, if the touch parameter + // is true, we will go ahead and touch all related models to sync. + $results = $query->delete(); + } + + if ($touch) { + $this->touchIfTouching(); + } + + return $results; + } + + /** + * Detach models from the relationship using a custom class. + */ + protected function detachUsingCustomClass(mixed $ids): int + { + $results = 0; + + $records = $this->getCurrentlyAttachedPivotsForIds($ids); + + foreach ($records as $record) { + $results += $record->delete(); + } + + return $results; + } + + /** + * Get the pivot models that are currently attached. + */ + protected function getCurrentlyAttachedPivots(): BaseCollection + { + return $this->getCurrentlyAttachedPivotsForIds(); + } + + /** + * Get the pivot models that are currently attached, filtered by related model keys. + */ + protected function getCurrentlyAttachedPivotsForIds(mixed $ids = null): BaseCollection + { + return $this->newPivotQuery() + ->when(! is_null($ids), fn ($query) => $query->whereIn( + $this->getQualifiedRelatedPivotKeyName(), + $this->parseIds($ids) + )) + ->get() + ->map(function ($record) { + $class = $this->using ?: Pivot::class; + + $pivot = $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true); + + return $pivot + ->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey) + ->setRelatedModel($this->related); + }); + } + + /** + * Create a new pivot model instance. + */ + public function newPivot(array $attributes = [], bool $exists = false): Pivot + { + $attributes = array_merge(array_column($this->pivotValues, 'value', 'column'), $attributes); + + $pivot = $this->related->newPivot( + $this->parent, + $attributes, + $this->table, + $exists, + $this->using + ); + + return $pivot + ->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey) + ->setRelatedModel($this->related); + } + + /** + * Create a new existing pivot model instance. + */ + public function newExistingPivot(array $attributes = []): Pivot + { + return $this->newPivot($attributes, true); + } + + /** + * Get a new plain query builder for the pivot table. + */ + public function newPivotStatement(): QueryBuilder + { + return $this->query->getQuery()->newQuery()->from($this->table); + } + + /** + * Get a new pivot statement for a given "other" ID. + */ + public function newPivotStatementForId(mixed $id): QueryBuilder + { + return $this->newPivotQuery()->whereIn($this->getQualifiedRelatedPivotKeyName(), $this->parseIds($id)); + } + + /** + * Create a new query builder for the pivot table. + */ + public function newPivotQuery(): QueryBuilder + { + $query = $this->newPivotStatement(); + + foreach ($this->pivotWheres as $arguments) { + $query->where(...$arguments); + } + + foreach ($this->pivotWhereIns as $arguments) { + $query->whereIn(...$arguments); + } + + foreach ($this->pivotWhereNulls as $arguments) { + $query->whereNull(...$arguments); + } + + return $query->where($this->getQualifiedForeignPivotKeyName(), $this->parent->{$this->parentKey}); + } + + /** + * Set the columns on the pivot table to retrieve. + * + * @return $this + */ + public function withPivot(mixed $columns): static + { + $this->pivotColumns = array_merge( + $this->pivotColumns, + is_array($columns) ? $columns : func_get_args() + ); + + return $this; + } + + /** + * Get all of the IDs from the given mixed value. + */ + protected function parseIds(mixed $value): array + { + if ($value instanceof Model) { + return [$value->{$this->relatedKey}]; + } + + if ($value instanceof EloquentCollection) { + return $value->pluck($this->relatedKey)->all(); + } + + if ($value instanceof BaseCollection || is_array($value)) { + return (new BaseCollection($value)) + ->map(fn ($item) => $item instanceof Model ? $item->{$this->relatedKey} : $item) + ->all(); + } + + return (array) $value; + } + + /** + * Get the ID from the given mixed value. + */ + protected function parseId(mixed $value): mixed + { + return $value instanceof Model ? $value->{$this->relatedKey} : $value; + } + + /** + * Cast the given keys to integers if they are numeric and string otherwise. + */ + protected function castKeys(array $keys): array + { + return array_map(function ($v) { + return $this->castKey($v); + }, $keys); + } + + /** + * Cast the given key to convert to primary key type. + */ + protected function castKey(mixed $key): mixed + { + return $this->getTypeSwapValue( + $this->related->getKeyType(), + $key + ); + } + + /** + * Cast the given pivot attributes. + */ + protected function castAttributes(array $attributes): array + { + return $this->using + ? $this->newPivot()->fill($attributes)->getAttributes() + : $attributes; + } + + /** + * Converts a given value to a given type value. + */ + protected function getTypeSwapValue(string $type, mixed $value): mixed + { + return match (strtolower($type)) { + 'int', 'integer' => (int) $value, + 'real', 'float', 'double' => (float) $value, + 'string' => (string) $value, + default => $value, + }; + } +} diff --git a/src/database/src/Eloquent/Relations/Concerns/SupportsDefaultModels.php b/src/database/src/Eloquent/Relations/Concerns/SupportsDefaultModels.php new file mode 100644 index 000000000..25fc7c7fe --- /dev/null +++ b/src/database/src/Eloquent/Relations/Concerns/SupportsDefaultModels.php @@ -0,0 +1,57 @@ +withDefault = $callback; + + return $this; + } + + /** + * Get the default value for this relation. + */ + protected function getDefaultFor(Model $parent): ?Model + { + if (! $this->withDefault) { + return null; + } + + $instance = $this->newRelatedInstanceFor($parent); + + if (is_callable($this->withDefault)) { + return call_user_func($this->withDefault, $instance, $parent) ?: $instance; + } + + if (is_array($this->withDefault)) { + $instance->forceFill($this->withDefault); + } + + return $instance; + } +} diff --git a/src/database/src/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/src/database/src/Eloquent/Relations/Concerns/SupportsInverseRelations.php new file mode 100644 index 000000000..d2428e49d --- /dev/null +++ b/src/database/src/Eloquent/Relations/Concerns/SupportsInverseRelations.php @@ -0,0 +1,148 @@ +chaperone($relation); + } + + /** + * Instruct Eloquent to link the related models back to the parent after the relationship query has run. + * + * @return $this + */ + public function chaperone(?string $relation = null): static + { + $relation ??= $this->guessInverseRelation(); + + if (! $relation || ! $this->getModel()->isRelation($relation)) { + throw RelationNotFoundException::make($this->getModel(), $relation ?: 'null'); + } + + if ($this->inverseRelationship === null && $relation) { + $this->query->afterQuery(function ($result) { + return $this->inverseRelationship + ? $this->applyInverseRelationToCollection($result, $this->getParent()) + : $result; + }); + } + + $this->inverseRelationship = $relation; + + return $this; + } + + /** + * Guess the name of the inverse relationship. + */ + protected function guessInverseRelation(): ?string + { + return Arr::first( + $this->getPossibleInverseRelations(), + fn ($relation) => $relation && $this->getModel()->isRelation($relation) + ); + } + + /** + * Get the possible inverse relations for the parent model. + * + * @return array + */ + protected function getPossibleInverseRelations(): array + { + return array_filter(array_unique([ + Str::camel(Str::beforeLast($this->getForeignKeyName(), $this->getParent()->getKeyName())), + Str::camel(Str::beforeLast($this->getParent()->getForeignKey(), $this->getParent()->getKeyName())), + Str::camel(class_basename($this->getParent())), + 'owner', + get_class($this->getParent()) === get_class($this->getModel()) ? 'parent' : null, + ])); + } + + /** + * Set the inverse relation on all models in a collection. + * + * @template TCollection of \Hypervel\Database\Eloquent\Collection + * @param TCollection $models + * @return TCollection + */ + protected function applyInverseRelationToCollection(mixed $models, ?Model $parent = null): mixed + { + $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); + } + + return $models; + } + + /** + * Set the inverse relation on a model. + */ + protected function applyInverseRelationToModel(Model $model, ?Model $parent = null): Model + { + if ($inverse = $this->getInverseRelationship()) { + $parent ??= $this->getParent(); + + $model->setRelation($inverse, $parent); + } + + return $model; + } + + /** + * Get the name of the inverse relationship. + */ + public function getInverseRelationship(): ?string + { + return $this->inverseRelationship; + } + + /** + * Remove the chaperone / inverse relationship for this query. + * + * Alias of "withoutChaperone". + * + * @return $this + */ + public function withoutInverse(): static + { + return $this->withoutChaperone(); + } + + /** + * Remove the chaperone / inverse relationship for this query. + * + * @return $this + */ + public function withoutChaperone(): static + { + $this->inverseRelationship = null; + + return $this; + } +} diff --git a/src/database/src/Eloquent/Relations/HasMany.php b/src/database/src/Eloquent/Relations/HasMany.php new file mode 100644 index 000000000..2cea59893 --- /dev/null +++ b/src/database/src/Eloquent/Relations/HasMany.php @@ -0,0 +1,59 @@ +> + */ +class HasMany extends HasOneOrMany +{ + /** + * Convert the relationship to a "has one" relationship. + * + * @return \Hypervel\Database\Eloquent\Relations\HasOne + */ + public function one(): HasOne + { + return HasOne::noConstraints(fn () => tap( + new HasOne( + $this->getQuery(), + $this->parent, + $this->foreignKey, + $this->localKey + ), + function ($hasOne) { + if ($inverse = $this->getInverseRelationship()) { + $hasOne->inverse($inverse); + } + } + )); + } + + public function getResults() + { + return ! is_null($this->getParentKey()) + ? $this->query->get() + : $this->related->newCollection(); + } + + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, $this->related->newCollection()); + } + + return $models; + } + + public function match(array $models, EloquentCollection $results, string $relation): array + { + return $this->matchMany($models, $results, $relation); + } +} diff --git a/src/database/src/Eloquent/Relations/HasManyThrough.php b/src/database/src/Eloquent/Relations/HasManyThrough.php new file mode 100644 index 000000000..17bada27b --- /dev/null +++ b/src/database/src/Eloquent/Relations/HasManyThrough.php @@ -0,0 +1,77 @@ +> + */ +class HasManyThrough extends HasOneOrManyThrough +{ + use InteractsWithDictionary; + + /** + * Convert the relationship to a "has one through" relationship. + * + * @return \Hypervel\Database\Eloquent\Relations\HasOneThrough + */ + public function one(): HasOneThrough + { + // @phpstan-ignore return.type (template types lost through closure/tap in noConstraints) + return HasOneThrough::noConstraints(fn () => new HasOneThrough( + tap($this->getQuery(), fn (Builder $query) => $query->getQuery()->joins = []), + $this->farParent, + $this->throughParent, + $this->getFirstKeyName(), + $this->getForeignKeyName(), + $this->getLocalKeyName(), + $this->getSecondLocalKeyName(), + )); + } + + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, $this->related->newCollection()); + } + + return $models; + } + + public function match(array $models, EloquentCollection $results, string $relation): array + { + $dictionary = $this->buildDictionary($results); + + // Once we have the dictionary we can simply spin through the parent models to + // link them up with their children using the keyed dictionary to make the + // matching very convenient and easy work. Then we'll just return them. + foreach ($models as $model) { + $key = $this->getDictionaryKey($model->getAttribute($this->localKey)); + + if ($key !== null && isset($dictionary[$key])) { + $model->setRelation( + $relation, + $this->related->newCollection($dictionary[$key]) + ); + } + } + + return $models; + } + + public function getResults() + { + return ! is_null($this->farParent->{$this->localKey}) + ? $this->get() + : $this->related->newCollection(); + } +} diff --git a/src/database/src/Eloquent/Relations/HasOne.php b/src/database/src/Eloquent/Relations/HasOne.php new file mode 100644 index 000000000..cf0a3d5b7 --- /dev/null +++ b/src/database/src/Eloquent/Relations/HasOne.php @@ -0,0 +1,109 @@ + + */ +class HasOne extends HasOneOrMany implements SupportsPartialRelations +{ + use ComparesRelatedModels; + use CanBeOneOfMany; + use SupportsDefaultModels; + + public function getResults() + { + if (is_null($this->getParentKey())) { + return $this->getDefaultFor($this->parent); + } + + return $this->query->first() ?: $this->getDefaultFor($this->parent); + } + + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, $this->getDefaultFor($model)); + } + + return $models; + } + + public function match(array $models, EloquentCollection $results, string $relation): array + { + return $this->matchOne($models, $results, $relation); + } + + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + if ($this->isOneOfMany()) { + $this->mergeOneOfManyJoinsTo($query); + } + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + /** + * Add constraints for inner join subselect for one of many relationships. + * + * @param \Hypervel\Database\Eloquent\Builder $query + */ + public function addOneOfManySubQueryConstraints(Builder $query, ?string $column = null, ?string $aggregate = null): void + { + $query->addSelect($this->foreignKey); + } + + /** + * Get the columns that should be selected by the one of many subquery. + */ + public function getOneOfManySubQuerySelectColumns(): array|string + { + return $this->foreignKey; + } + + /** + * Add join query constraints for one of many relationships. + */ + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join): void + { + $join->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); + } + + /** + * Make a new related instance for the given model. + * + * @param TDeclaringModel $parent + * @return TRelatedModel + */ + public function newRelatedInstanceFor(Model $parent): Model + { + return tap($this->related->newInstance(), function ($instance) use ($parent) { + $instance->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}); + $this->applyInverseRelationToModel($instance, $parent); + }); + } + + /** + * Get the value of the model's foreign key. + * + * @param TRelatedModel $model + */ + protected function getRelatedKeyFrom(Model $model): mixed + { + return $model->getAttribute($this->getForeignKeyName()); + } +} diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php new file mode 100755 index 000000000..c3ad4daab --- /dev/null +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -0,0 +1,557 @@ + + */ +abstract class HasOneOrMany extends Relation +{ + use InteractsWithDictionary; + use SupportsInverseRelations; + + /** + * The foreign key of the parent model. + */ + protected string $foreignKey; + + /** + * The local key of the parent model. + */ + protected string $localKey; + + /** + * Create a new has one or many relationship instance. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + */ + public function __construct(Builder $query, Model $parent, string $foreignKey, string $localKey) + { + $this->localKey = $localKey; + $this->foreignKey = $foreignKey; + + parent::__construct($query, $parent); + } + + /** + * Create and return an un-saved instance of the related model. + * + * @return TRelatedModel + */ + public function make(array $attributes = []): Model + { + return tap($this->related->newInstance($attributes), function ($instance) { + $this->setForeignAttributesForCreate($instance); + $this->applyInverseRelationToModel($instance); + }); + } + + /** + * Create and return an un-saved instance of the related models. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + public function makeMany(iterable $records): EloquentCollection + { + $instances = $this->related->newCollection(); + + foreach ($records as $record) { + $instances->push($this->make($record)); + } + + return $instances; + } + + /** + * Set the base constraints on the relation query. + */ + public function addConstraints(): void + { + if (static::shouldAddConstraints()) { + $query = $this->getRelationQuery(); + + $query->where($this->foreignKey, '=', $this->getParentKey()); + + $query->whereNotNull($this->foreignKey); + } + } + + public function addEagerConstraints(array $models): void + { + $whereIn = $this->whereInMethod($this->parent, $this->localKey); + + $this->whereInEager( + $whereIn, + $this->foreignKey, + $this->getKeys($models, $this->localKey), + $this->getRelationQuery() + ); + } + + /** + * Match the eagerly loaded results to their single parents. + * + * @param array $models + * @param \Hypervel\Database\Eloquent\Collection $results + * @return array + */ + public function matchOne(array $models, EloquentCollection $results, string $relation): array + { + return $this->matchOneOrMany($models, $results, $relation, 'one'); + } + + /** + * Match the eagerly loaded results to their many parents. + * + * @param array $models + * @param \Hypervel\Database\Eloquent\Collection $results + * @return array + */ + public function matchMany(array $models, EloquentCollection $results, string $relation): array + { + return $this->matchOneOrMany($models, $results, $relation, 'many'); + } + + /** + * Match the eagerly loaded results to their many parents. + * + * @param array $models + * @param \Hypervel\Database\Eloquent\Collection $results + * @return array + */ + protected function matchOneOrMany(array $models, EloquentCollection $results, string $relation, string $type): array + { + $dictionary = $this->buildDictionary($results); + + // Once we have the dictionary we can simply spin through the parent models to + // link them up with their children using the keyed dictionary to make the + // matching very convenient and easy work. Then we'll just return them. + foreach ($models as $model) { + $key = $this->getDictionaryKey($model->getAttribute($this->localKey)); + + if ($key !== null && isset($dictionary[$key])) { + $related = $this->getRelationValue($dictionary, $key, $type); + + $model->setRelation($relation, $related); + + // Apply the inverse relation if we have one... + $type === 'one' + ? $this->applyInverseRelationToModel($related, $model) + : $this->applyInverseRelationToCollection($related, $model); + } + } + + return $models; + } + + /** + * Get the value of a relationship by one or many type. + */ + protected function getRelationValue(array $dictionary, int|string $key, string $type): mixed + { + $value = $dictionary[$key]; + + return $type === 'one' ? reset($value) : $this->related->newCollection($value); + } + + /** + * Build model dictionary keyed by the relation's foreign key. + * + * @param \Hypervel\Database\Eloquent\Collection $results + * @return array> + */ + protected function buildDictionary(EloquentCollection $results): array + { + $foreign = $this->getForeignKeyName(); + + return $results->mapToDictionary(function ($result) use ($foreign) { + return [$this->getDictionaryKey($result->{$foreign}) => $result]; + })->all(); + } + + /** + * Find a model by its primary key or return a new instance of the related model. + * + * @return ($id is (array|\Hypervel\Contracts\Support\Arrayable) ? \Hypervel\Database\Eloquent\Collection : TRelatedModel) + */ + public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection|Model + { + if (is_null($instance = $this->find($id, $columns))) { + $instance = $this->related->newInstance(); + + $this->setForeignAttributesForCreate($instance); + } + + return $instance; + } + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @return TRelatedModel + */ + public function firstOrNew(array $attributes = [], array $values = []): Model + { + if (is_null($instance = $this->where($attributes)->first())) { + $instance = $this->related->newInstance(array_merge($attributes, $values)); + + $this->setForeignAttributesForCreate($instance); + } + + return $instance; + } + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @return TRelatedModel + */ + public function firstOrCreate(array $attributes = [], array $values = []): Model + { + if (is_null($instance = (clone $this)->where($attributes)->first())) { + $instance = $this->createOrFirst($attributes, $values); + } + + return $instance; + } + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @return TRelatedModel + */ + public function createOrFirst(array $attributes = [], array $values = []): Model + { + try { + // @phpstan-ignore return.type (generic type lost through withSavepointIfNeeded callback) + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values))); + } catch (UniqueConstraintViolationException $e) { + // @phpstan-ignore return.type (generic type lost through where()->first() chain) + return $this->useWritePdo()->where($attributes)->first() ?? throw $e; + } + } + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @return TRelatedModel + */ + public function updateOrCreate(array $attributes, array $values = []): Model + { + return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values)->save(); + } + }); + } + + /** + * Insert new records or update the existing ones. + */ + public function upsert(array $values, array|string $uniqueBy, ?array $update = null): int + { + if (! empty($values) && ! is_array(Arr::first($values))) { + $values = [$values]; + } + + foreach ($values as $key => $value) { + $values[$key][$this->getForeignKeyName()] = $this->getParentKey(); + } + + return $this->getQuery()->upsert($values, $uniqueBy, $update); + } + + /** + * Attach a model instance to the parent model. + * + * @param TRelatedModel $model + * @return false|TRelatedModel + */ + public function save(Model $model): Model|false + { + $this->setForeignAttributesForCreate($model); + + return $model->save() ? $model : false; + } + + /** + * Attach a model instance without raising any events to the parent model. + * + * @param TRelatedModel $model + * @return false|TRelatedModel + */ + public function saveQuietly(Model $model): Model|false + { + return Model::withoutEvents(function () use ($model) { + return $this->save($model); + }); + } + + /** + * Attach a collection of models to the parent instance. + * + * @param iterable $models + * @return iterable + */ + public function saveMany(iterable $models): iterable + { + foreach ($models as $model) { + $this->save($model); + } + + return $models; + } + + /** + * Attach a collection of models to the parent instance without raising any events to the parent model. + * + * @param iterable $models + * @return iterable + */ + public function saveManyQuietly(iterable $models): iterable + { + return Model::withoutEvents(function () use ($models) { + return $this->saveMany($models); + }); + } + + /** + * Create a new instance of the related model. + * + * @return TRelatedModel + */ + public function create(array $attributes = []): Model + { + return tap($this->related->newInstance($attributes), function ($instance) { + $this->setForeignAttributesForCreate($instance); + + $instance->save(); + + $this->applyInverseRelationToModel($instance); + }); + } + + /** + * Create a new instance of the related model without raising any events to the parent model. + * + * @return TRelatedModel + */ + public function createQuietly(array $attributes = []): Model + { + return Model::withoutEvents(fn () => $this->create($attributes)); + } + + /** + * Create a new instance of the related model. Allow mass-assignment. + * + * @return TRelatedModel + */ + public function forceCreate(array $attributes = []): Model + { + $attributes[$this->getForeignKeyName()] = $this->getParentKey(); + + return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); + } + + /** + * Create a new instance of the related model with mass assignment without raising model events. + * + * @return TRelatedModel + */ + public function forceCreateQuietly(array $attributes = []): Model + { + return Model::withoutEvents(fn () => $this->forceCreate($attributes)); + } + + /** + * Create a Collection of new instances of the related model. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + public function createMany(iterable $records): EloquentCollection + { + $instances = $this->related->newCollection(); + + foreach ($records as $record) { + $instances->push($this->create($record)); + } + + return $instances; + } + + /** + * Create a Collection of new instances of the related model without raising any events to the parent model. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + public function createManyQuietly(iterable $records): EloquentCollection + { + return Model::withoutEvents(fn () => $this->createMany($records)); + } + + /** + * Create a Collection of new instances of the related model, allowing mass-assignment. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + public function forceCreateMany(iterable $records): EloquentCollection + { + $instances = $this->related->newCollection(); + + foreach ($records as $record) { + $instances->push($this->forceCreate($record)); + } + + return $instances; + } + + /** + * Create a Collection of new instances of the related model, allowing mass-assignment and without raising any events to the parent model. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + public function forceCreateManyQuietly(iterable $records): EloquentCollection + { + return Model::withoutEvents(fn () => $this->forceCreateMany($records)); + } + + /** + * Set the foreign ID for creating a related model. + * + * @param TRelatedModel $model + */ + protected function setForeignAttributesForCreate(Model $model): void + { + $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); + + foreach ($this->getQuery()->pendingAttributes as $key => $value) { + $attributes ??= $model->getAttributes(); + + if (! array_key_exists($key, $attributes)) { + $model->setAttribute($key, $value); + } + } + + $this->applyInverseRelationToModel($model); + } + + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + if ($query->getQuery()->from == $parentQuery->getQuery()->from) { + return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); + } + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + /** + * Add the constraints for a relationship query on the same table. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param \Hypervel\Database\Eloquent\Builder $parentQuery + * @return \Hypervel\Database\Eloquent\Builder + */ + public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + $query->from($query->getModel()->getTable() . ' as ' . $hash = $this->getRelationCountHash()); + + $query->getModel()->setTable($hash); + + return $query->select($columns)->whereColumn( + $this->getQualifiedParentKeyName(), + '=', + $hash . '.' . $this->getForeignKeyName() + ); + } + + /** + * Alias to set the "limit" value of the query. + * + * @return $this + */ + public function take(int $value): static + { + return $this->limit($value); + } + + /** + * Set the "limit" value of the query. + * + * @return $this + */ + public function limit(int $value): static + { + if ($this->parent->exists) { + $this->query->limit($value); + } else { + $this->query->groupLimit($value, $this->getExistenceCompareKey()); + } + + return $this; + } + + /** + * Get the key for comparing against the parent key in "has" query. + */ + public function getExistenceCompareKey(): string + { + return $this->getQualifiedForeignKeyName(); + } + + /** + * Get the key value of the parent's local key. + */ + public function getParentKey(): mixed + { + return $this->parent->getAttribute($this->localKey); + } + + /** + * Get the fully qualified parent key name. + */ + public function getQualifiedParentKeyName(): string + { + return $this->parent->qualifyColumn($this->localKey); + } + + /** + * Get the plain foreign key. + */ + public function getForeignKeyName(): string + { + $segments = explode('.', $this->getQualifiedForeignKeyName()); + + return Arr::last($segments); + } + + /** + * Get the foreign key for the relationship. + */ + public function getQualifiedForeignKeyName(): string + { + return $this->foreignKey; + } + + /** + * Get the local key for the relationship. + */ + public function getLocalKeyName(): string + { + return $this->localKey; + } +} diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php new file mode 100644 index 000000000..4f4bf9a4b --- /dev/null +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -0,0 +1,768 @@ + + */ +abstract class HasOneOrManyThrough extends Relation +{ + use InteractsWithDictionary; + + /** + * The "through" parent model instance. + * + * @var TIntermediateModel + */ + protected Model $throughParent; + + /** + * The far parent model instance. + * + * @var TDeclaringModel + */ + protected Model $farParent; + + /** + * The near key on the relationship. + */ + protected string $firstKey; + + /** + * The far key on the relationship. + */ + protected string $secondKey; + + /** + * The local key on the relationship. + */ + protected string $localKey; + + /** + * The local key on the intermediary model. + */ + protected string $secondLocalKey; + + /** + * Create a new has many through relationship instance. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $farParent + * @param TIntermediateModel $throughParent + */ + public function __construct(Builder $query, Model $farParent, Model $throughParent, string $firstKey, string $secondKey, string $localKey, string $secondLocalKey) + { + $this->localKey = $localKey; + $this->firstKey = $firstKey; + $this->secondKey = $secondKey; + $this->farParent = $farParent; + $this->throughParent = $throughParent; + $this->secondLocalKey = $secondLocalKey; + + parent::__construct($query, $throughParent); + } + + /** + * Set the base constraints on the relation query. + */ + public function addConstraints(): void + { + $query = $this->getRelationQuery(); + + $localValue = $this->farParent[$this->localKey]; + + // @phpstan-ignore argument.type (Builder<*> vs Builder) + $this->performJoin($query); + + if (static::shouldAddConstraints()) { + $query->where($this->getQualifiedFirstKeyName(), '=', $localValue); + } + } + + /** + * Set the join clause on the query. + * + * @param null|\Hypervel\Database\Eloquent\Builder $query + */ + protected function performJoin(?Builder $query = null): void + { + $query ??= $this->query; + + $farKey = $this->getQualifiedFarKeyName(); + + $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey); + + if ($this->throughParentSoftDeletes()) { + $query->withGlobalScope('SoftDeletableHasManyThrough', function ($query) { + $query->whereNull($this->throughParent->getQualifiedDeletedAtColumn()); + }); + } + } + + /** + * Get the fully qualified parent key name. + */ + public function getQualifiedParentKeyName(): string + { + return $this->parent->qualifyColumn($this->secondLocalKey); + } + + /** + * Determine whether "through" parent of the relation uses Soft Deletes. + */ + public function throughParentSoftDeletes(): bool + { + return $this->throughParent::isSoftDeletable(); + } + + /** + * Indicate that trashed "through" parents should be included in the query. + * + * @return $this + */ + public function withTrashedParents(): static + { + $this->query->withoutGlobalScope('SoftDeletableHasManyThrough'); + + return $this; + } + + public function addEagerConstraints(array $models): void + { + $whereIn = $this->whereInMethod($this->farParent, $this->localKey); + + $this->whereInEager( + $whereIn, + $this->getQualifiedFirstKeyName(), + $this->getKeys($models, $this->localKey), + $this->getRelationQuery(), + ); + } + + /** + * Build model dictionary keyed by the relation's foreign key. + * + * @param \Hypervel\Database\Eloquent\Collection $results + * @return array> + */ + protected function buildDictionary(EloquentCollection $results): array + { + $dictionary = []; + + // First we will create a dictionary of models keyed by the foreign key of the + // relationship as this will allow us to quickly access all of the related + // models without having to do nested looping which will be quite slow. + foreach ($results as $result) { + // @phpstan-ignore property.notFound (laravel_through_key is a select alias added during query) + $dictionary[$result->laravel_through_key][] = $result; + } + + return $dictionary; + } + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @return TRelatedModel + */ + public function firstOrNew(array $attributes = [], array $values = []): Model + { + if (! is_null($instance = $this->where($attributes)->first())) { + return $instance; + } + + return $this->related->newInstance(array_merge($attributes, $values)); + } + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @return TRelatedModel + */ + public function firstOrCreate(array $attributes = [], array $values = []): Model + { + if (! is_null($instance = (clone $this)->where($attributes)->first())) { + return $instance; + } + + return $this->createOrFirst(array_merge($attributes, $values)); + } + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @return TRelatedModel + */ + public function createOrFirst(array $attributes = [], array $values = []): Model + { + try { + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values))); + } catch (UniqueConstraintViolationException $exception) { + return $this->where($attributes)->first() ?? throw $exception; + } + } + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @return TRelatedModel + */ + public function updateOrCreate(array $attributes, array $values = []): Model + { + return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values)->save(); + } + }); + } + + /** + * Add a basic where clause to the query, and return the first result. + * + * @return null|TRelatedModel + */ + public function firstWhere(Closure|string|array $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model + { + return $this->where($column, $operator, $value, $boolean)->first(); + } + + /** + * Execute the query and get the first related model. + * + * @return null|TRelatedModel + */ + public function first(array $columns = ['*']): ?Model + { + $results = $this->limit(1)->get($columns); + + return count($results) > 0 ? $results->first() : null; + } + + /** + * Execute the query and get the first result or throw an exception. + * + * @return TRelatedModel + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + */ + public function firstOrFail(array $columns = ['*']): Model + { + if (! is_null($model = $this->first($columns))) { + return $model; + } + + throw (new ModelNotFoundException())->setModel(get_class($this->related)); + } + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list $columns + * @param null|(Closure(): TValue) $callback + * @return TRelatedModel|TValue + */ + public function firstOr(Closure|array $columns = ['*'], ?Closure $callback = null): mixed + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->first($columns))) { + return $model; + } + + return $callback(); + } + + /** + * Find a related model by its primary key. + * + * @return ($id is (array|\Hypervel\Contracts\Support\Arrayable) ? \Hypervel\Database\Eloquent\Collection : null|TRelatedModel) + */ + public function find(mixed $id, array $columns = ['*']): EloquentCollection|Model|null + { + if (is_array($id) || $id instanceof Arrayable) { + return $this->findMany($id, $columns); + } + + return $this->where( + $this->getRelated()->getQualifiedKeyName(), + '=', + $id + )->first($columns); + } + + /** + * Find a sole related model by its primary key. + * + * @return TRelatedModel + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function findSole(mixed $id, array $columns = ['*']): Model + { + return $this->where( + $this->getRelated()->getQualifiedKeyName(), + '=', + $id + )->sole($columns); + } + + /** + * Find multiple related models by their primary keys. + * + * @param array|\Hypervel\Contracts\Support\Arrayable $ids + * @return \Hypervel\Database\Eloquent\Collection + */ + public function findMany(Arrayable|array $ids, array $columns = ['*']): EloquentCollection + { + $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; + + if (empty($ids)) { + return $this->getRelated()->newCollection(); + } + + return $this->whereIn( + $this->getRelated()->getQualifiedKeyName(), + $ids + )->get($columns); + } + + /** + * Find a related model by its primary key or throw an exception. + * + * @return ($id is (array|\Hypervel\Contracts\Support\Arrayable) ? \Hypervel\Database\Eloquent\Collection : TRelatedModel) + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + */ + public function findOrFail(mixed $id, array $columns = ['*']): EloquentCollection|Model + { + $result = $this->find($id, $columns); + + $id = $id instanceof Arrayable ? $id->toArray() : $id; + + if (is_array($id)) { + if (count($result) === count(array_unique($id))) { + return $result; + } + } elseif (! is_null($result)) { + return $result; + } + + throw (new ModelNotFoundException())->setModel(get_class($this->related), $id); + } + + /** + * Find a related model by its primary key or call a callback. + * + * @template TValue + * + * @param (Closure(): TValue)|list|string $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is (\Hypervel\Contracts\Support\Arrayable|array) + * ? \Hypervel\Database\Eloquent\Collection|TValue + * : TRelatedModel|TValue + * ) + */ + public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closure $callback = null): mixed + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + $result = $this->find($id, $columns); + + $id = $id instanceof Arrayable ? $id->toArray() : $id; + + if (is_array($id)) { + if (count($result) === count(array_unique($id))) { + return $result; + } + } elseif (! is_null($result)) { + return $result; + } + + return $callback(); + } + + public function get(array $columns = ['*']): EloquentCollection + { + $builder = $this->prepareQueryBuilder($columns); + + $models = $builder->getModels(); + + // If we actually found models we will also eager load any relationships that + // have been specified as needing to be eager loaded. This will solve the + // n + 1 query problem for the developer and also increase performance. + if (count($models) > 0) { + $models = $builder->eagerLoadRelations($models); + } + + return $this->query->applyAfterQueryCallbacks( + $this->related->newCollection($models) + ); + } + + /** + * Get a paginator for the "select" statement. + * + * @return \Hypervel\Pagination\LengthAwarePaginator + */ + public function paginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null): mixed + { + $this->query->addSelect($this->shouldSelect($columns)); + + return $this->query->paginate($perPage, $columns, $pageName, $page); + } + + /** + * Paginate the given query into a simple paginator. + * + * @return \Hypervel\Contracts\Pagination\Paginator + */ + public function simplePaginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null): mixed + { + $this->query->addSelect($this->shouldSelect($columns)); + + return $this->query->simplePaginate($perPage, $columns, $pageName, $page); + } + + /** + * Paginate the given query into a cursor paginator. + * + * @return \Hypervel\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate(?int $perPage = null, array $columns = ['*'], string $cursorName = 'cursor', ?string $cursor = null): mixed + { + $this->query->addSelect($this->shouldSelect($columns)); + + return $this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor); + } + + /** + * Set the select clause for the relation query. + */ + protected function shouldSelect(array $columns = ['*']): array + { + if ($columns == ['*']) { + $columns = [$this->related->qualifyColumn('*')]; + } + + return array_merge($columns, [$this->getQualifiedFirstKeyName() . ' as laravel_through_key']); + } + + /** + * Chunk the results of the query. + */ + public function chunk(int $count, callable $callback): bool + { + return $this->prepareQueryBuilder()->chunk($count, $callback); + } + + /** + * Chunk the results of a query by comparing numeric IDs. + */ + public function chunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool + { + $column ??= $this->getRelated()->getQualifiedKeyName(); + + $alias ??= $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->chunkById($count, $callback, $column, $alias); + } + + /** + * Chunk the results of a query by comparing IDs in descending order. + */ + public function chunkByIdDesc(int $count, callable $callback, ?string $column = null, ?string $alias = null): bool + { + $column ??= $this->getRelated()->getQualifiedKeyName(); + + $alias ??= $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->chunkByIdDesc($count, $callback, $column, $alias); + } + + /** + * Execute a callback over each item while chunking by ID. + */ + public function eachById(callable $callback, int $count = 1000, ?string $column = null, ?string $alias = null): bool + { + $column = $column ?? $this->getRelated()->getQualifiedKeyName(); + + $alias = $alias ?? $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->eachById($callback, $count, $column, $alias); + } + + /** + * Get a generator for the given query. + * + * @return \Hypervel\Support\LazyCollection + */ + public function cursor(): mixed + { + return $this->prepareQueryBuilder()->cursor(); + } + + /** + * Execute a callback over each item while chunking. + */ + public function each(callable $callback, int $count = 1000): bool + { + return $this->chunk($count, function ($results) use ($callback) { + foreach ($results as $key => $value) { + if ($callback($value, $key) === false) { + return false; + } + } + }); + } + + /** + * Query lazily, by chunks of the given size. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazy(int $chunkSize = 1000): mixed + { + return $this->prepareQueryBuilder()->lazy($chunkSize); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): mixed + { + $column ??= $this->getRelated()->getQualifiedKeyName(); + + $alias ??= $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->lazyById($chunkSize, $column, $alias); + } + + /** + * Query lazily, by chunking the results of a query by comparing IDs in descending order. + * + * @return \Hypervel\Support\LazyCollection + */ + public function lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?string $alias = null): mixed + { + $column ??= $this->getRelated()->getQualifiedKeyName(); + + $alias ??= $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->lazyByIdDesc($chunkSize, $column, $alias); + } + + /** + * Prepare the query builder for query execution. + * + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function prepareQueryBuilder(array $columns = ['*']): Builder + { + $builder = $this->query->applyScopes(); + + return $builder->addSelect( + $this->shouldSelect($builder->getQuery()->columns ? [] : $columns) + ); + } + + 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); + } + + // @phpstan-ignore argument.type (Builder<*> vs Builder) + $this->performJoin($query); + + return $query->select($columns)->whereColumn( + $this->getQualifiedLocalKeyName(), + '=', + $this->getQualifiedFirstKeyName() + ); + } + + /** + * Add the constraints for a relationship query on the same table. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param \Hypervel\Database\Eloquent\Builder $parentQuery + * @return \Hypervel\Database\Eloquent\Builder + */ + public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + $query->from($query->getModel()->getTable() . ' as ' . $hash = $this->getRelationCountHash()); + + $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $hash . '.' . $this->secondKey); + + if ($this->throughParentSoftDeletes()) { + $query->whereNull($this->throughParent->getQualifiedDeletedAtColumn()); + } + + $query->getModel()->setTable($hash); + + return $query->select($columns)->whereColumn( + $parentQuery->getQuery()->from . '.' . $this->localKey, + '=', + $this->getQualifiedFirstKeyName() + ); + } + + /** + * Add the constraints for a relationship query on the same table as the through parent. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param \Hypervel\Database\Eloquent\Builder $parentQuery + * @return \Hypervel\Database\Eloquent\Builder + */ + public function getRelationExistenceQueryForThroughSelfRelation(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + $table = $this->throughParent->getTable() . ' as ' . $hash = $this->getRelationCountHash(); + + $query->join($table, $hash . '.' . $this->secondLocalKey, '=', $this->getQualifiedFarKeyName()); + + if ($this->throughParentSoftDeletes()) { + $query->whereNull($hash . '.' . $this->throughParent->getDeletedAtColumn()); + } + + return $query->select($columns)->whereColumn( + $parentQuery->getQuery()->from . '.' . $this->localKey, + '=', + $hash . '.' . $this->firstKey + ); + } + + /** + * Alias to set the "limit" value of the query. + * + * @return $this + */ + public function take(int $value): static + { + return $this->limit($value); + } + + /** + * Set the "limit" value of the query. + * + * @return $this + */ + public function limit(int $value): static + { + if ($this->farParent->exists) { + $this->query->limit($value); + } else { + $column = $this->getQualifiedFirstKeyName(); + + $grammar = $this->query->getQuery()->getGrammar(); + + if ($grammar instanceof MySqlGrammar && $grammar->useLegacyGroupLimit($this->query->getQuery())) { + $column = 'laravel_through_key'; + } + + $this->query->groupLimit($value, $column); + } + + return $this; + } + + /** + * Get the qualified foreign key on the related model. + */ + public function getQualifiedFarKeyName(): string + { + return $this->getQualifiedForeignKeyName(); + } + + /** + * Get the foreign key on the "through" model. + */ + public function getFirstKeyName(): string + { + return $this->firstKey; + } + + /** + * Get the qualified foreign key on the "through" model. + */ + public function getQualifiedFirstKeyName(): string + { + return $this->throughParent->qualifyColumn($this->firstKey); + } + + /** + * Get the foreign key on the related model. + */ + public function getForeignKeyName(): string + { + return $this->secondKey; + } + + /** + * Get the qualified foreign key on the related model. + */ + public function getQualifiedForeignKeyName(): string + { + return $this->related->qualifyColumn($this->secondKey); + } + + /** + * Get the local key on the far parent model. + */ + public function getLocalKeyName(): string + { + return $this->localKey; + } + + /** + * Get the qualified local key on the far parent model. + */ + public function getQualifiedLocalKeyName(): string + { + return $this->farParent->qualifyColumn($this->localKey); + } + + /** + * Get the local key on the intermediary model. + */ + public function getSecondLocalKeyName(): string + { + return $this->secondLocalKey; + } +} diff --git a/src/database/src/Eloquent/Relations/HasOneThrough.php b/src/database/src/Eloquent/Relations/HasOneThrough.php new file mode 100644 index 000000000..63d2ef6e7 --- /dev/null +++ b/src/database/src/Eloquent/Relations/HasOneThrough.php @@ -0,0 +1,122 @@ + + */ +class HasOneThrough extends HasOneOrManyThrough implements SupportsPartialRelations +{ + use ComparesRelatedModels; + use CanBeOneOfMany; + use InteractsWithDictionary; + use SupportsDefaultModels; + + public function getResults() + { + if (is_null($this->getParentKey())) { + return $this->getDefaultFor($this->farParent); + } + + return $this->first() ?: $this->getDefaultFor($this->farParent); + } + + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, $this->getDefaultFor($model)); + } + + return $models; + } + + public function match(array $models, EloquentCollection $results, string $relation): array + { + $dictionary = $this->buildDictionary($results); + + // Once we have the dictionary we can simply spin through the parent models to + // link them up with their children using the keyed dictionary to make the + // matching very convenient and easy work. Then we'll just return them. + foreach ($models as $model) { + $key = $this->getDictionaryKey($model->getAttribute($this->localKey)); + + if ($key !== null && isset($dictionary[$key])) { + $value = $dictionary[$key]; + + $model->setRelation( + $relation, + reset($value) + ); + } + } + + return $models; + } + + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + if ($this->isOneOfMany()) { + $this->mergeOneOfManyJoinsTo($query); + } + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + public function addOneOfManySubQueryConstraints(Builder $query, ?string $column = null, ?string $aggregate = null): void + { + $query->addSelect([$this->getQualifiedFirstKeyName()]); + + // We need to join subqueries that aren't the inner-most subquery which is joined in the CanBeOneOfMany::ofMany method... + if ($this->getOneOfManySubQuery() !== null) { + // @phpstan-ignore argument.type (Builder param typed without template in inherited interface) + $this->performJoin($query); + } + } + + public function getOneOfManySubQuerySelectColumns(): array|string + { + return [$this->getQualifiedFirstKeyName()]; + } + + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join): void + { + $join->on($this->qualifySubSelectColumn($this->firstKey), '=', $this->getQualifiedFirstKeyName()); + } + + /** + * Make a new related instance for the given model. + * + * @param TDeclaringModel $parent + * @return TRelatedModel + */ + public function newRelatedInstanceFor(Model $parent): Model + { + return $this->related->newInstance(); + } + + protected function getRelatedKeyFrom(Model $model): mixed + { + return $model->getAttribute($this->getForeignKeyName()); + } + + public function getParentKey(): mixed + { + return $this->farParent->getAttribute($this->localKey); + } +} diff --git a/src/database/src/Eloquent/Relations/MorphMany.php b/src/database/src/Eloquent/Relations/MorphMany.php new file mode 100644 index 000000000..0603fea1c --- /dev/null +++ b/src/database/src/Eloquent/Relations/MorphMany.php @@ -0,0 +1,68 @@ +> + */ +class MorphMany extends MorphOneOrMany +{ + /** + * Convert the relationship to a "morph one" relationship. + * + * @return \Hypervel\Database\Eloquent\Relations\MorphOne + */ + public function one(): MorphOne + { + return MorphOne::noConstraints(fn () => tap( + new MorphOne( + $this->getQuery(), + $this->getParent(), + $this->morphType, + $this->foreignKey, + $this->localKey + ), + function ($morphOne) { + if ($inverse = $this->getInverseRelationship()) { + $morphOne->inverse($inverse); + } + } + )); + } + + public function getResults() + { + return ! is_null($this->getParentKey()) + ? $this->query->get() + : $this->related->newCollection(); + } + + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, $this->related->newCollection()); + } + + return $models; + } + + public function match(array $models, EloquentCollection $results, string $relation): array + { + return $this->matchMany($models, $results, $relation); + } + + public function forceCreate(array $attributes = []): Model + { + $attributes[$this->getMorphType()] = $this->morphClass; + + return parent::forceCreate($attributes); + } +} diff --git a/src/database/src/Eloquent/Relations/MorphOne.php b/src/database/src/Eloquent/Relations/MorphOne.php new file mode 100644 index 000000000..fa74ba494 --- /dev/null +++ b/src/database/src/Eloquent/Relations/MorphOne.php @@ -0,0 +1,113 @@ + + */ +class MorphOne extends MorphOneOrMany implements SupportsPartialRelations +{ + use CanBeOneOfMany; + use ComparesRelatedModels; + use SupportsDefaultModels; + + public function getResults() + { + if (is_null($this->getParentKey())) { + return $this->getDefaultFor($this->parent); + } + + return $this->query->first() ?: $this->getDefaultFor($this->parent); + } + + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, $this->getDefaultFor($model)); + } + + return $models; + } + + public function match(array $models, EloquentCollection $results, string $relation): array + { + return $this->matchOne($models, $results, $relation); + } + + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + if ($this->isOneOfMany()) { + $this->mergeOneOfManyJoinsTo($query); + } + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + /** + * Add constraints for inner join subselect for one of many relationships. + * + * @param \Hypervel\Database\Eloquent\Builder $query + */ + public function addOneOfManySubQueryConstraints(Builder $query, ?string $column = null, ?string $aggregate = null): void + { + $query->addSelect($this->foreignKey, $this->morphType); + } + + /** + * Get the columns that should be selected by the one of many subquery. + */ + public function getOneOfManySubQuerySelectColumns(): array|string + { + return [$this->foreignKey, $this->morphType]; + } + + /** + * Add join query constraints for one of many relationships. + */ + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join): void + { + $join + ->on($this->qualifySubSelectColumn($this->morphType), '=', $this->qualifyRelatedColumn($this->morphType)) + ->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); + } + + /** + * Make a new related instance for the given model. + * + * @param TDeclaringModel $parent + * @return TRelatedModel + */ + public function newRelatedInstanceFor(Model $parent): Model + { + return tap($this->related->newInstance(), function ($instance) use ($parent) { + $instance->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}) + ->setAttribute($this->getMorphType(), $this->morphClass); + + $this->applyInverseRelationToModel($instance, $parent); + }); + } + + /** + * Get the value of the model's foreign key. + * + * @param TRelatedModel $model + */ + protected function getRelatedKeyFrom(Model $model): mixed + { + return $model->getAttribute($this->getForeignKeyName()); + } +} diff --git a/src/database/src/Eloquent/Relations/MorphOneOrMany.php b/src/database/src/Eloquent/Relations/MorphOneOrMany.php new file mode 100644 index 000000000..afd156628 --- /dev/null +++ b/src/database/src/Eloquent/Relations/MorphOneOrMany.php @@ -0,0 +1,164 @@ + + */ +abstract class MorphOneOrMany extends HasOneOrMany +{ + /** + * The foreign key type for the relationship. + */ + protected string $morphType; + + /** + * The class name of the parent model. + * + * @var class-string + */ + protected string $morphClass; + + /** + * Create a new morph one or many relationship instance. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + */ + public function __construct(Builder $query, Model $parent, string $type, string $id, string $localKey) + { + $this->morphType = $type; + + $this->morphClass = $parent->getMorphClass(); + + parent::__construct($query, $parent, $id, $localKey); + } + + /** + * Set the base constraints on the relation query. + */ + public function addConstraints(): void + { + if (static::shouldAddConstraints()) { + $this->getRelationQuery()->where($this->morphType, $this->morphClass); + + parent::addConstraints(); + } + } + + public function addEagerConstraints(array $models): void + { + parent::addEagerConstraints($models); + + $this->getRelationQuery()->where($this->morphType, $this->morphClass); + } + + /** + * Create a new instance of the related model. Allow mass-assignment. + * + * @return TRelatedModel + */ + public function forceCreate(array $attributes = []): Model + { + $attributes[$this->getForeignKeyName()] = $this->getParentKey(); + $attributes[$this->getMorphType()] = $this->morphClass; + + return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); + } + + /** + * Set the foreign ID and type for creating a related model. + * + * @param TRelatedModel $model + */ + protected function setForeignAttributesForCreate(Model $model): void + { + $model->{$this->getForeignKeyName()} = $this->getParentKey(); + + $model->{$this->getMorphType()} = $this->morphClass; + + foreach ($this->getQuery()->pendingAttributes as $key => $value) { + $attributes ??= $model->getAttributes(); + + if (! array_key_exists($key, $attributes)) { + $model->setAttribute($key, $value); + } + } + + $this->applyInverseRelationToModel($model); + } + + /** + * Insert new records or update the existing ones. + */ + public function upsert(array $values, array|string $uniqueBy, ?array $update = null): int + { + if (! empty($values) && ! is_array(Arr::first($values))) { + $values = [$values]; + } + + foreach ($values as $key => $value) { + $values[$key][$this->getMorphType()] = $this->getMorphClass(); + } + + return parent::upsert($values, $uniqueBy, $update); + } + + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + return parent::getRelationExistenceQuery($query, $parentQuery, $columns)->where( + $query->qualifyColumn($this->getMorphType()), + $this->morphClass + ); + } + + /** + * Get the foreign key "type" name. + */ + public function getQualifiedMorphType(): string + { + return $this->morphType; + } + + /** + * Get the plain morph type name without the table. + */ + public function getMorphType(): string + { + return last(explode('.', $this->morphType)); + } + + /** + * Get the class name of the parent model. + * + * @return class-string + */ + public function getMorphClass(): string + { + return $this->morphClass; + } + + /** + * Get the possible inverse relations for the parent model. + * + * @return array + */ + protected function getPossibleInverseRelations(): array + { + return array_unique([ + Str::beforeLast($this->getMorphType(), '_type'), + ...parent::getPossibleInverseRelations(), + ]); + } +} diff --git a/src/database/src/Eloquent/Relations/MorphPivot.php b/src/database/src/Eloquent/Relations/MorphPivot.php new file mode 100644 index 000000000..a38c82bd8 --- /dev/null +++ b/src/database/src/Eloquent/Relations/MorphPivot.php @@ -0,0 +1,180 @@ + $query + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function setKeysForSaveQuery(Builder $query): Builder + { + $query->where($this->morphType, $this->morphClass); + + return parent::setKeysForSaveQuery($query); + } + + /** + * Set the keys for a select query. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function setKeysForSelectQuery(Builder $query): Builder + { + $query->where($this->morphType, $this->morphClass); + + return parent::setKeysForSelectQuery($query); + } + + /** + * Delete the pivot model record from the database. + */ + public function delete(): int + { + if (isset($this->attributes[$this->getKeyName()])) { + return (int) parent::delete(); + } + + if ($this->fireModelEvent('deleting') === false) { + return 0; + } + + $query = $this->getDeleteQuery(); + + $query->where($this->morphType, $this->morphClass); + + return tap($query->delete(), function () { + $this->exists = false; + + $this->fireModelEvent('deleted', false); + }); + } + + /** + * Get the morph type for the pivot. + */ + public function getMorphType(): string + { + return $this->morphType; + } + + /** + * Set the morph type for the pivot. + * + * @return $this + */ + public function setMorphType(string $morphType): static + { + $this->morphType = $morphType; + + return $this; + } + + /** + * Set the morph class for the pivot. + * + * @param class-string $morphClass + * @return $this + */ + public function setMorphClass(string $morphClass): static + { + $this->morphClass = $morphClass; + + return $this; + } + + /** + * Get the queueable identity for the entity. + */ + public function getQueueableId(): mixed + { + if (isset($this->attributes[$this->getKeyName()])) { + return $this->getKey(); + } + + return sprintf( + '%s:%s:%s:%s:%s:%s', + $this->foreignKey, + $this->getAttribute($this->foreignKey), + $this->relatedKey, + $this->getAttribute($this->relatedKey), + $this->morphType, + $this->morphClass + ); + } + + /** + * Get a new query to restore one or more models by their queueable IDs. + * + * @return \Hypervel\Database\Eloquent\Builder + */ + public function newQueryForRestoration(array|int|string $ids): Builder + { + if (is_array($ids)) { + return $this->newQueryForCollectionRestoration($ids); + } + + if (! str_contains($ids, ':')) { + return parent::newQueryForRestoration($ids); + } + + $segments = explode(':', $ids); + + return $this->newQueryWithoutScopes() + ->where($segments[0], $segments[1]) + ->where($segments[2], $segments[3]) + ->where($segments[4], $segments[5]); + } + + /** + * Get a new query to restore multiple models by their queueable IDs. + * + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function newQueryForCollectionRestoration(array $ids): Builder + { + $ids = array_values($ids); + + if (! str_contains($ids[0], ':')) { + return parent::newQueryForRestoration($ids); + } + + $query = $this->newQueryWithoutScopes(); + + foreach ($ids as $id) { + $segments = explode(':', $id); + + $query->orWhere(function ($query) use ($segments) { + return $query->where($segments[0], $segments[1]) + ->where($segments[2], $segments[3]) + ->where($segments[4], $segments[5]); + }); + } + + return $query; + } +} diff --git a/src/database/src/Eloquent/Relations/MorphTo.php b/src/database/src/Eloquent/Relations/MorphTo.php new file mode 100644 index 000000000..19f817418 --- /dev/null +++ b/src/database/src/Eloquent/Relations/MorphTo.php @@ -0,0 +1,422 @@ + + */ +class MorphTo extends BelongsTo +{ + use InteractsWithDictionary; + + /** + * The type of the polymorphic relation. + */ + protected string $morphType; + + /** + * The associated key on the parent model. + */ + protected ?string $ownerKey; + + /** + * The models whose relations are being eager loaded. + * + * @var \Hypervel\Database\Eloquent\Collection + */ + protected EloquentCollection $models; + + /** + * All of the models keyed by ID. + */ + protected array $dictionary = []; + + /** + * A buffer of dynamic calls to query macros. + */ + protected array $macroBuffer = []; + + /** + * A map of relations to load for each individual morph type. + */ + protected array $morphableEagerLoads = []; + + /** + * A map of relationship counts to load for each individual morph type. + */ + protected array $morphableEagerLoadCounts = []; + + /** + * A map of constraints to apply for each individual morph type. + */ + protected array $morphableConstraints = []; + + /** + * Create a new morph to relationship instance. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + */ + public function __construct(Builder $query, Model $parent, string $foreignKey, ?string $ownerKey, string $type, string $relation) + { + $this->morphType = $type; + + parent::__construct($query, $parent, $foreignKey, $ownerKey, $relation); + } + + #[Override] + public function addEagerConstraints(array $models): void + { + // @phpstan-ignore argument.type (MorphTo eager loading uses declaring model, not related model) + $this->buildDictionary($this->models = new EloquentCollection($models)); + } + + /** + * Build a dictionary with the models. + * + * @param \Hypervel\Database\Eloquent\Collection $models + */ + protected function buildDictionary(EloquentCollection $models): void + { + foreach ($models as $model) { + if ($model->{$this->morphType}) { + $morphTypeKey = $this->getDictionaryKey($model->{$this->morphType}); + $foreignKeyKey = $this->getDictionaryKey($model->{$this->foreignKey}); + + $this->dictionary[$morphTypeKey][$foreignKeyKey][] = $model; + } + } + } + + /** + * Get the results of the relationship. + * + * Called via eager load method of Eloquent query builder. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + public function getEager(): EloquentCollection + { + foreach (array_keys($this->dictionary) as $type) { + $this->matchToMorphParents($type, $this->getResultsByType($type)); + } + + return $this->models; + } + + /** + * Get all of the relation results for a type. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + protected function getResultsByType(string $type): EloquentCollection + { + $instance = $this->createModelByType($type); + + $ownerKey = $this->ownerKey ?? $instance->getKeyName(); + + $query = $this->replayMacros($instance->newQuery()) + ->mergeConstraintsFrom($this->getQuery()) + ->with(array_merge( + $this->getQuery()->getEagerLoads(), + (array) ($this->morphableEagerLoads[get_class($instance)] ?? []) + )) + ->withCount( + (array) ($this->morphableEagerLoadCounts[get_class($instance)] ?? []) + ); + + if ($callback = ($this->morphableConstraints[get_class($instance)] ?? null)) { + $callback($query); + } + + $whereIn = $this->whereInMethod($instance, $ownerKey); + + return $query->{$whereIn}( + $instance->qualifyColumn($ownerKey), + $this->gatherKeysByType($type, $instance->getKeyType()) + )->get(); + } + + /** + * Gather all of the foreign keys for a given type. + */ + protected function gatherKeysByType(string $type, string $keyType): array + { + return $keyType !== 'string' + ? array_keys($this->dictionary[$type]) + : array_map(function ($modelId) { + return (string) $modelId; + }, array_filter(array_keys($this->dictionary[$type]))); + } + + /** + * Create a new model instance by type. + * + * @return TRelatedModel + */ + public function createModelByType(string $type): Model + { + $class = Model::getActualClassNameForMorph($type); + + return tap(new $class(), function ($instance) { + if (! $instance->getConnectionName()) { + $instance->setConnection($this->getConnection()->getName()); + } + }); + } + + #[Override] + public function match(array $models, EloquentCollection $results, string $relation): array + { + return $models; + } + + /** + * Match the results for a given type to their parents. + * + * @param \Hypervel\Database\Eloquent\Collection $results + */ + protected function matchToMorphParents(string $type, EloquentCollection $results): void + { + foreach ($results as $result) { + $ownerKey = ! is_null($this->ownerKey) ? $this->getDictionaryKey($result->{$this->ownerKey}) : $result->getKey(); + + if (isset($this->dictionary[$type][$ownerKey])) { + foreach ($this->dictionary[$type][$ownerKey] as $model) { + $model->setRelation($this->relationName, $result); + } + } + } + } + + /** + * Associate the model instance to the given parent. + * + * @param null|TRelatedModel $model + * @return TDeclaringModel + */ + #[Override] + public function associate(Model|string|int|null $model): Model + { + if ($model instanceof Model) { + $foreignKey = $this->ownerKey && $model->{$this->ownerKey} + ? $this->ownerKey + : $model->getKeyName(); + } + + $this->parent->setAttribute( + $this->foreignKey, + $model instanceof Model ? $model->{$foreignKey} : null + ); + + $this->parent->setAttribute( + $this->morphType, + $model instanceof Model ? $model->getMorphClass() : null + ); + + return $this->parent->setRelation($this->relationName, $model); + } + + /** + * Dissociate previously associated model from the given parent. + * + * @return TDeclaringModel + */ + #[Override] + public function dissociate(): Model + { + $this->parent->setAttribute($this->foreignKey, null); + + $this->parent->setAttribute($this->morphType, null); + + return $this->parent->setRelation($this->relationName, null); + } + + #[Override] + public function touch(): void + { + if (! is_null($this->getParentKey())) { + parent::touch(); + } + } + + #[Override] + protected function newRelatedInstanceFor(Model $parent): Model + { + return $parent->{$this->getRelationName()}()->getRelated()->newInstance(); + } + + /** + * Get the foreign key "type" name. + */ + public function getMorphType(): string + { + return $this->morphType; + } + + /** + * Get the dictionary used by the relationship. + */ + public function getDictionary(): array + { + return $this->dictionary; + } + + /** + * Specify which relations to load for a given morph type. + * + * @return $this + */ + public function morphWith(array $with): static + { + $this->morphableEagerLoads = array_merge( + $this->morphableEagerLoads, + $with + ); + + return $this; + } + + /** + * Specify which relationship counts to load for a given morph type. + * + * @return $this + */ + public function morphWithCount(array $withCount): static + { + $this->morphableEagerLoadCounts = array_merge( + $this->morphableEagerLoadCounts, + $withCount + ); + + return $this; + } + + /** + * Specify constraints on the query for a given morph type. + * + * @return $this + */ + public function constrain(array $callbacks): static + { + $this->morphableConstraints = array_merge( + $this->morphableConstraints, + $callbacks + ); + + return $this; + } + + /** + * Indicate that soft deleted models should be included in the results. + * + * @return $this + */ + public function withTrashed(): static + { + $callback = fn ($query) => $query->hasMacro('withTrashed') ? $query->withTrashed() : $query; + + $this->macroBuffer[] = [ + 'method' => 'when', + 'parameters' => [true, $callback], + ]; + + return $this->when(true, $callback); + } + + /** + * Indicate that soft deleted models should not be included in the results. + * + * @return $this + */ + public function withoutTrashed(): static + { + $callback = fn ($query) => $query->hasMacro('withoutTrashed') ? $query->withoutTrashed() : $query; + + $this->macroBuffer[] = [ + 'method' => 'when', + 'parameters' => [true, $callback], + ]; + + return $this->when(true, $callback); + } + + /** + * Indicate that only soft deleted models should be included in the results. + * + * @return $this + */ + public function onlyTrashed(): static + { + $callback = fn ($query) => $query->hasMacro('onlyTrashed') ? $query->onlyTrashed() : $query; + + $this->macroBuffer[] = [ + 'method' => 'when', + 'parameters' => [true, $callback], + ]; + + return $this->when(true, $callback); + } + + /** + * Replay stored macro calls on the actual related instance. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function replayMacros(Builder $query): Builder + { + foreach ($this->macroBuffer as $macro) { + $query->{$macro['method']}(...$macro['parameters']); + } + + return $query; + } + + #[Override] + public function getQualifiedOwnerKeyName(): string + { + if (is_null($this->ownerKey)) { + return ''; + } + + return parent::getQualifiedOwnerKeyName(); + } + + /** + * Handle dynamic method calls to the relationship. + */ + public function __call(string $method, array $parameters): mixed + { + try { + $result = parent::__call($method, $parameters); + + if (in_array($method, ['select', 'selectRaw', 'selectSub', 'addSelect', 'withoutGlobalScopes'])) { + $this->macroBuffer[] = compact('method', 'parameters'); + } + + return $result; + } + + // If we tried to call a method that does not exist on the parent Builder instance, + // we'll assume that we want to call a query macro (e.g. withTrashed) that only + // exists on related models. We will just store the call and replay it later. + catch (BadMethodCallException) { + $this->macroBuffer[] = compact('method', 'parameters'); + + return $this; + } + } +} diff --git a/src/database/src/Eloquent/Relations/MorphToMany.php b/src/database/src/Eloquent/Relations/MorphToMany.php new file mode 100644 index 000000000..1571c11d8 --- /dev/null +++ b/src/database/src/Eloquent/Relations/MorphToMany.php @@ -0,0 +1,214 @@ + + */ +class MorphToMany extends BelongsToMany +{ + /** + * The type of the polymorphic relation. + */ + protected string $morphType; + + /** + * The class name of the morph type constraint. + * + * @var class-string + */ + protected string $morphClass; + + /** + * Indicates if we are connecting the inverse of the relation. + * + * This primarily affects the morphClass constraint. + */ + protected bool $inverse; + + /** + * Create a new morph to many relationship instance. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + */ + public function __construct( + Builder $query, + Model $parent, + string $name, + string $table, + string $foreignPivotKey, + string $relatedPivotKey, + string $parentKey, + string $relatedKey, + ?string $relationName = null, + bool $inverse = false, + ) { + $this->inverse = $inverse; + $this->morphType = $name . '_type'; + $this->morphClass = $inverse ? $query->getModel()->getMorphClass() : $parent->getMorphClass(); + + parent::__construct( + $query, + $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName + ); + } + + /** + * Set the where clause for the relation query. + * + * @return $this + */ + protected function addWhereConstraints(): static + { + parent::addWhereConstraints(); + + $this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass); + + return $this; + } + + public function addEagerConstraints(array $models): void + { + parent::addEagerConstraints($models); + + $this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass); + } + + /** + * Create a new pivot attachment record. + */ + protected function baseAttachRecord(mixed $id, bool $timed): array + { + return Arr::add( + parent::baseAttachRecord($id, $timed), + $this->morphType, + $this->morphClass + ); + } + + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + return parent::getRelationExistenceQuery($query, $parentQuery, $columns)->where( + $this->qualifyPivotColumn($this->morphType), + $this->morphClass + ); + } + + /** + * Get the pivot models that are currently attached, filtered by related model keys. + * + * @return \Hypervel\Support\Collection + */ + protected function getCurrentlyAttachedPivotsForIds(mixed $ids = null): Collection + { + return parent::getCurrentlyAttachedPivotsForIds($ids)->map(function ($record) { + return $record instanceof MorphPivot + ? $record->setMorphType($this->morphType) + ->setMorphClass($this->morphClass) + : $record; + }); + } + + /** + * Create a new query builder for the pivot table. + */ + public function newPivotQuery(): QueryBuilder + { + return parent::newPivotQuery()->where($this->morphType, $this->morphClass); + } + + /** + * Create a new pivot model instance. + * + * @return TPivotModel + */ + public function newPivot(array $attributes = [], bool $exists = false): Pivot + { + $using = $this->using; + + $attributes = array_merge([$this->morphType => $this->morphClass], $attributes); + + $pivot = $using + ? $using::fromRawAttributes($this->parent, $attributes, $this->table, $exists) + : MorphPivot::fromAttributes($this->parent, $attributes, $this->table, $exists); + + $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey) + ->setRelatedModel($this->related) + ->setMorphType($this->morphType) + ->setMorphClass($this->morphClass); + + return $pivot; + } + + /** + * Get the pivot columns for the relation. + * + * "pivot_" is prefixed at each column for easy removal later. + */ + protected function aliasedPivotColumns(): array + { + return (new Collection([ + $this->foreignPivotKey, + $this->relatedPivotKey, + $this->morphType, + ...$this->pivotColumns, + ])) + ->map(fn ($column) => $this->qualifyPivotColumn($column) . ' as pivot_' . $column) + ->unique() + ->all(); + } + + /** + * Get the foreign key "type" name. + */ + public function getMorphType(): string + { + return $this->morphType; + } + + /** + * Get the fully qualified morph type for the relation. + */ + public function getQualifiedMorphTypeName(): string + { + return $this->qualifyPivotColumn($this->morphType); + } + + /** + * Get the class name of the parent model. + * + * @return class-string + */ + public function getMorphClass(): string + { + return $this->morphClass; + } + + /** + * Get the indicator for a reverse relationship. + */ + public function getInverse(): bool + { + return $this->inverse; + } +} diff --git a/src/database/src/Eloquent/Relations/Pivot.php b/src/database/src/Eloquent/Relations/Pivot.php new file mode 100644 index 000000000..57b097377 --- /dev/null +++ b/src/database/src/Eloquent/Relations/Pivot.php @@ -0,0 +1,25 @@ + + */ + protected array $guarded = []; +} diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php new file mode 100644 index 000000000..1b779b05b --- /dev/null +++ b/src/database/src/Eloquent/Relations/Relation.php @@ -0,0 +1,507 @@ + + */ +abstract class Relation implements BuilderContract +{ + use ForwardsCalls, Macroable { + Macroable::__call as macroCall; + } + + /** + * The Eloquent query builder instance. + * + * @var \Hypervel\Database\Eloquent\Builder + */ + protected Builder $query; + + /** + * The parent model instance. + * + * @var TDeclaringModel + */ + protected Model $parent; + + /** + * The related model instance. + * + * @var TRelatedModel + */ + protected Model $related; + + /** + * Indicates whether the eagerly loaded relation should implicitly return an empty collection. + */ + protected bool $eagerKeysWereEmpty = false; + + /** + * The context key for storing whether constraints are enabled. + */ + protected const CONSTRAINTS_CONTEXT_KEY = '__database.relation.constraints'; + + /** + * An array to map morph names to their class names in the database. + * + * @var array> + */ + public static array $morphMap = []; + + /** + * Prevents morph relationships without a morph map. + */ + protected static bool $requireMorphMap = false; + + /** + * The count of self joins. + */ + protected static int $selfJoinCount = 0; + + /** + * Create a new relation instance. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + */ + public function __construct(Builder $query, Model $parent) + { + $this->query = $query; + $this->parent = $parent; + $this->related = $query->getModel(); + + $this->addConstraints(); + } + + /** + * Run a callback with constraints disabled on the relation. + * + * @template TReturn of mixed + * + * @param Closure(): TReturn $callback + * @return TReturn + */ + public static function noConstraints(Closure $callback): mixed + { + $previous = Context::get(static::CONSTRAINTS_CONTEXT_KEY, true); + + Context::set(static::CONSTRAINTS_CONTEXT_KEY, false); + + // When resetting the relation where clause, we want to shift the first element + // off of the bindings, leaving only the constraints that the developers put + // as "extra" on the relationships, and not original relation constraints. + try { + return $callback(); + } finally { + Context::set(static::CONSTRAINTS_CONTEXT_KEY, $previous); + } + } + + /** + * Determine if constraints should be added to the relation query. + */ + public static function shouldAddConstraints(): bool + { + return Context::get(static::CONSTRAINTS_CONTEXT_KEY, true); + } + + /** + * Set the base constraints on the relation query. + */ + abstract public function addConstraints(): void; + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + */ + abstract public function addEagerConstraints(array $models): void; + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @return array + */ + abstract public function initRelation(array $models, string $relation): array; + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param \Hypervel\Database\Eloquent\Collection $results + * @return array + */ + abstract public function match(array $models, EloquentCollection $results, string $relation): array; + + /** + * Get the results of the relationship. + * + * @return TResult + */ + abstract public function getResults(); + + /** + * Get the relationship for eager loading. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + public function getEager(): EloquentCollection + { + return $this->eagerKeysWereEmpty + ? $this->related->newCollection() + : $this->get(); + } + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @return TRelatedModel + * + * @throws \Hypervel\Database\Eloquent\ModelNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function sole(array|string $columns = ['*']): Model + { + $result = $this->limit(2)->get($columns); + + $count = $result->count(); + + if ($count === 0) { + throw (new ModelNotFoundException())->setModel(get_class($this->related)); + } + + if ($count > 1) { + throw new MultipleRecordsFoundException($count); + } + + // @phpstan-ignore return.type (Collection::first() generic type lost; count check above ensures non-null) + return $result->first(); + } + + /** + * Execute the query as a "select" statement. + * + * @return \Hypervel\Database\Eloquent\Collection + */ + public function get(array $columns = ['*']): EloquentCollection + { + return $this->query->get($columns); + } + + /** + * Touch all of the related models for the relationship. + */ + public function touch(): void + { + $model = $this->getRelated(); + + if (! $model::isIgnoringTouch()) { + $this->rawUpdate([ + $model->getUpdatedAtColumn() => $model->freshTimestampString(), + ]); + } + } + + /** + * Run a raw update against the base query. + */ + public function rawUpdate(array $attributes = []): int + { + return $this->query->withoutGlobalScopes()->update($attributes); + } + + /** + * Add the constraints for a relationship count query. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param \Hypervel\Database\Eloquent\Builder $parentQuery + * @return \Hypervel\Database\Eloquent\Builder + */ + public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery): Builder + { + return $this->getRelationExistenceQuery( + $query, + $parentQuery, + new Expression('count(*)') + )->setBindings([], 'select'); + } + + /** + * Add the constraints for an internal relationship existence query. + * + * Essentially, these queries compare on column names like whereColumn. + * + * @param \Hypervel\Database\Eloquent\Builder $query + * @param \Hypervel\Database\Eloquent\Builder $parentQuery + * @return \Hypervel\Database\Eloquent\Builder + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder + { + return $query->select($columns)->whereColumn( + $this->getQualifiedParentKeyName(), + '=', + $this->getExistenceCompareKey() // @phpstan-ignore method.notFound (defined in subclasses) + ); + } + + /** + * Get a relationship join table hash. + */ + public function getRelationCountHash(bool $incrementJoinCount = true): string + { + return 'laravel_reserved_' . ($incrementJoinCount ? static::$selfJoinCount++ : static::$selfJoinCount); + } + + /** + * Get all of the primary keys for an array of models. + * + * @param array $models + * @return array + */ + protected function getKeys(array $models, ?string $key = null): array + { + return (new BaseCollection($models))->map(function ($value) use ($key) { + return $key ? $value->getAttribute($key) : $value->getKey(); + })->values()->unique(null, true)->sort()->all(); + } + + /** + * Get the query builder that will contain the relationship constraints. + * + * @return \Hypervel\Database\Eloquent\Builder + */ + protected function getRelationQuery(): Builder + { + return $this->query; + } + + /** + * Get the underlying query for the relation. + * + * @return \Hypervel\Database\Eloquent\Builder + */ + public function getQuery(): Builder + { + return $this->query; + } + + /** + * Get the base query builder driving the Eloquent builder. + */ + public function getBaseQuery(): QueryBuilder + { + return $this->query->getQuery(); + } + + /** + * Get a base query builder instance. + */ + public function toBase(): QueryBuilder + { + return $this->query->toBase(); + } + + /** + * Get the parent model of the relation. + * + * @return TDeclaringModel + */ + public function getParent(): Model + { + return $this->parent; + } + + /** + * Get the fully qualified parent key name. + */ + public function getQualifiedParentKeyName(): string + { + return $this->parent->getQualifiedKeyName(); + } + + /** + * Get the related model of the relation. + * + * @return TRelatedModel + */ + public function getRelated(): Model + { + return $this->related; + } + + /** + * Get the name of the "created at" column. + */ + public function createdAt(): string + { + return $this->parent->getCreatedAtColumn(); + } + + /** + * Get the name of the "updated at" column. + */ + public function updatedAt(): string + { + return $this->parent->getUpdatedAtColumn(); + } + + /** + * Get the name of the related model's "updated at" column. + */ + public function relatedUpdatedAt(): string + { + return $this->related->getUpdatedAtColumn(); + } + + /** + * Add a whereIn eager constraint for the given set of model keys to be loaded. + * + * @param null|\Hypervel\Database\Eloquent\Builder $query + */ + protected function whereInEager(string $whereIn, string $key, array $modelKeys, ?Builder $query = null): void + { + ($query ?? $this->query)->{$whereIn}($key, $modelKeys); + + if ($modelKeys === []) { + $this->eagerKeysWereEmpty = true; + } + } + + /** + * Get the name of the "where in" method for eager loading. + */ + protected function whereInMethod(Model $model, string $key): string + { + return $model->getKeyName() === last(explode('.', $key)) + && in_array($model->getKeyType(), ['int', 'integer']) + ? 'whereIntegerInRaw' + : 'whereIn'; + } + + /** + * Prevent polymorphic relationships from being used without model mappings. + */ + public static function requireMorphMap(bool $requireMorphMap = true): void + { + static::$requireMorphMap = $requireMorphMap; + } + + /** + * Determine if polymorphic relationships require explicit model mapping. + */ + public static function requiresMorphMap(): bool + { + return static::$requireMorphMap; + } + + /** + * Define the morph map for polymorphic relations and require all morphed models to be explicitly mapped. + * + * @param array> $map + */ + public static function enforceMorphMap(array $map, bool $merge = true): array + { + static::requireMorphMap(); + + return static::morphMap($map, $merge); + } + + /** + * Set or get the morph map for polymorphic relations. + * + * @param null|array> $map + * @return array> + */ + public static function morphMap(?array $map = null, bool $merge = true): array + { + $map = static::buildMorphMapFromModels($map); + + if (is_array($map)) { + static::$morphMap = $merge && static::$morphMap + ? $map + static::$morphMap + : $map; + } + + return static::$morphMap; + } + + /** + * Builds a table-keyed array from model class names. + * + * @param null|array>|list> $models + * @return null|array> + */ + protected static function buildMorphMapFromModels(?array $models = null): ?array + { + if (is_null($models) || ! array_is_list($models)) { + // @phpstan-ignore return.type (returns the keyed array unchanged) + return $models; + } + + return array_combine(array_map(function ($model) { + return (new $model())->getTable(); + }, $models), $models); + } + + /** + * Get the model associated with a custom polymorphic type. + * + * @return null|class-string<\Hypervel\Database\Eloquent\Model> + */ + public static function getMorphedModel(string $alias): ?string + { + return static::$morphMap[$alias] ?? null; + } + + /** + * Get the alias associated with a custom polymorphic class. + * + * @param class-string<\Hypervel\Database\Eloquent\Model> $className + */ + public static function getMorphAlias(string $className): int|string + { + return array_search($className, static::$morphMap, strict: true) ?: $className; + } + + /** + * Handle dynamic method calls to the relationship. + */ + public function __call(string $method, array $parameters): mixed + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + return $this->forwardDecoratedCallTo($this->query, $method, $parameters); + } + + /** + * Force a clone of the underlying query builder when cloning. + */ + public function __clone(): void + { + $this->query = clone $this->query; + } +} diff --git a/src/database/src/Eloquent/Scope.php b/src/database/src/Eloquent/Scope.php new file mode 100644 index 000000000..519d29018 --- /dev/null +++ b/src/database/src/Eloquent/Scope.php @@ -0,0 +1,18 @@ + $builder + * @param TModel $model + */ + public function apply(Builder $builder, Model $model): void; +} diff --git a/src/database/src/Eloquent/SoftDeletes.php b/src/database/src/Eloquent/SoftDeletes.php new file mode 100644 index 000000000..68ef7f193 --- /dev/null +++ b/src/database/src/Eloquent/SoftDeletes.php @@ -0,0 +1,252 @@ + withTrashed(bool $withTrashed = true) + * @method static \Hypervel\Database\Eloquent\Builder onlyTrashed() + * @method static \Hypervel\Database\Eloquent\Builder withoutTrashed() + * @method static static restoreOrCreate(array $attributes = [], array $values = []) + * @method static static createOrRestore(array $attributes = [], array $values = []) + */ +trait SoftDeletes +{ + /** + * Indicates if the model is currently force deleting. + */ + protected bool $forceDeleting = false; + + /** + * Boot the soft deleting trait for a model. + */ + public static function bootSoftDeletes(): void + { + static::addGlobalScope(new SoftDeletingScope()); + } + + /** + * Initialize the soft deleting trait for an instance. + */ + public function initializeSoftDeletes(): void + { + if (! isset($this->casts[$this->getDeletedAtColumn()])) { + $this->casts[$this->getDeletedAtColumn()] = 'datetime'; + } + } + + /** + * Force a hard delete on a soft deleted model. + */ + public function forceDelete(): ?bool + { + if ($this->fireModelEvent('forceDeleting') === false) { + return false; + } + + $this->forceDeleting = true; + + return tap($this->delete(), function ($deleted) { + $this->forceDeleting = false; + + if ($deleted) { + $this->fireModelEvent('forceDeleted', false); + } + }); + } + + /** + * Force a hard delete on a soft deleted model without raising any events. + */ + public function forceDeleteQuietly(): ?bool + { + return static::withoutEvents(fn () => $this->forceDelete()); + } + + /** + * Destroy the models for the given IDs. + */ + public static function forceDestroy(Collection|BaseCollection|array|int|string $ids): int + { + if ($ids instanceof Collection) { + $ids = $ids->modelKeys(); + } + + if ($ids instanceof BaseCollection) { + $ids = $ids->all(); + } + + $ids = is_array($ids) ? $ids : func_get_args(); + + if (count($ids) === 0) { + return 0; + } + + // We will actually pull the models from the database table and call delete on + // each of them individually so that their events get fired properly with a + // correct set of attributes in case the developers wants to check these. + $key = ($instance = new static())->getKeyName(); + + $count = 0; + + foreach ($instance->withTrashed()->whereIn($key, $ids)->get() as $model) { + if ($model->forceDelete()) { + ++$count; + } + } + + return $count; + } + + /** + * Perform the actual delete query on this model instance. + */ + protected function performDeleteOnModel(): void + { + if ($this->forceDeleting) { + tap($this->setKeysForSaveQuery($this->newModelQuery())->forceDelete(), function () { + $this->exists = false; + }); + + return; + } + + $this->runSoftDelete(); + } + + /** + * Perform the actual delete query on this model instance. + */ + protected function runSoftDelete(): void + { + $query = $this->setKeysForSaveQuery($this->newModelQuery()); + + $time = $this->freshTimestamp(); + + $columns = [$this->getDeletedAtColumn() => $this->fromDateTime($time)]; + + $this->{$this->getDeletedAtColumn()} = $time; + + if ($this->usesTimestamps() && ! is_null($this->getUpdatedAtColumn())) { + $this->{$this->getUpdatedAtColumn()} = $time; + + $columns[$this->getUpdatedAtColumn()] = $this->fromDateTime($time); + } + + $query->update($columns); + + $this->syncOriginalAttributes(array_keys($columns)); + + $this->fireModelEvent('trashed', false); + } + + /** + * Restore a soft-deleted model instance. + */ + public function restore(): bool + { + // If the restoring event does not return false, we will proceed with this + // restore operation. Otherwise, we bail out so the developer will stop + // the restore totally. We will clear the deleted timestamp and save. + if ($this->fireModelEvent('restoring') === false) { + return false; + } + + $this->{$this->getDeletedAtColumn()} = null; + + // Once we have saved the model, we will fire the "restored" event so this + // developer will do anything they need to after a restore operation is + // totally finished. Then we will return the result of the save call. + $this->exists = true; + + $result = $this->save(); + + $this->fireModelEvent('restored', false); + + return $result; + } + + /** + * Restore a soft-deleted model instance without raising any events. + */ + public function restoreQuietly(): bool + { + return static::withoutEvents(fn () => $this->restore()); + } + + /** + * Determine if the model instance has been soft-deleted. + */ + public function trashed(): bool + { + return ! is_null($this->{$this->getDeletedAtColumn()}); + } + + /** + * Register a "softDeleted" model event callback with the dispatcher. + */ + public static function softDeleted(QueuedClosure|callable|string $callback): void + { + static::registerModelEvent('trashed', $callback); + } + + /** + * Register a "restoring" model event callback with the dispatcher. + */ + public static function restoring(QueuedClosure|callable|string $callback): void + { + static::registerModelEvent('restoring', $callback); + } + + /** + * Register a "restored" model event callback with the dispatcher. + */ + public static function restored(QueuedClosure|callable|string $callback): void + { + static::registerModelEvent('restored', $callback); + } + + /** + * Register a "forceDeleting" model event callback with the dispatcher. + */ + public static function forceDeleting(QueuedClosure|callable|string $callback): void + { + static::registerModelEvent('forceDeleting', $callback); + } + + /** + * Register a "forceDeleted" model event callback with the dispatcher. + */ + public static function forceDeleted(QueuedClosure|callable|string $callback): void + { + static::registerModelEvent('forceDeleted', $callback); + } + + /** + * Determine if the model is currently force deleting. + */ + public function isForceDeleting(): bool + { + return $this->forceDeleting; + } + + /** + * Get the name of the "deleted at" column. + */ + public function getDeletedAtColumn(): string + { + return defined(static::class . '::DELETED_AT') ? static::DELETED_AT : 'deleted_at'; + } + + /** + * Get the fully qualified "deleted at" column. + */ + public function getQualifiedDeletedAtColumn(): string + { + return $this->qualifyColumn($this->getDeletedAtColumn()); + } +} diff --git a/src/database/src/Eloquent/SoftDeletingScope.php b/src/database/src/Eloquent/SoftDeletingScope.php new file mode 100644 index 000000000..9f52d6ba7 --- /dev/null +++ b/src/database/src/Eloquent/SoftDeletingScope.php @@ -0,0 +1,163 @@ + $builder + * @param TModel $model + */ + public function apply(Builder $builder, Model $model): void + { + $builder->whereNull($model->getQualifiedDeletedAtColumn()); + } + + /** + * Extend the query builder with the needed functions. + * + * @param Builder<*> $builder + */ + public function extend(Builder $builder): void + { + foreach ($this->extensions as $extension) { + $this->{"add{$extension}"}($builder); + } + + $builder->onDelete(function (Builder $builder) { + $column = $this->getDeletedAtColumn($builder); + + return $builder->update([ + $column => $builder->getModel()->freshTimestampString(), + ]); + }); + } + + /** + * Get the "deleted at" column for the builder. + * + * @param Builder<*> $builder + */ + protected function getDeletedAtColumn(Builder $builder): string + { + if (count((array) $builder->getQuery()->joins) > 0) { + return $builder->getModel()->getQualifiedDeletedAtColumn(); + } + + return $builder->getModel()->getDeletedAtColumn(); + } + + /** + * Add the restore extension to the builder. + * + * @param Builder<*> $builder + */ + protected function addRestore(Builder $builder): void + { + $builder->macro('restore', function (Builder $builder) { + $builder->withTrashed(); + + return $builder->update([$builder->getModel()->getDeletedAtColumn() => null]); + }); + } + + /** + * Add the restore-or-create extension to the builder. + * + * @param Builder<*> $builder + */ + protected function addRestoreOrCreate(Builder $builder): void + { + $builder->macro('restoreOrCreate', function (Builder $builder, array $attributes = [], array $values = []) { + $builder->withTrashed(); + + return tap($builder->firstOrCreate($attributes, $values), function ($instance) { + $instance->restore(); + }); + }); + } + + /** + * Add the create-or-restore extension to the builder. + * + * @param Builder<*> $builder + */ + protected function addCreateOrRestore(Builder $builder): void + { + $builder->macro('createOrRestore', function (Builder $builder, array $attributes = [], array $values = []) { + $builder->withTrashed(); + + return tap($builder->createOrFirst($attributes, $values), function ($instance) { + $instance->restore(); + }); + }); + } + + /** + * Add the with-trashed extension to the builder. + * + * @param Builder<*> $builder + */ + protected function addWithTrashed(Builder $builder): void + { + $builder->macro('withTrashed', function (Builder $builder, bool $withTrashed = true) { + if (! $withTrashed) { + return $builder->withoutTrashed(); + } + + // @phpstan-ignore argument.type ($this is rebound to SoftDeletingScope when macro is called) + return $builder->withoutGlobalScope($this); + }); + } + + /** + * Add the without-trashed extension to the builder. + * + * @param Builder<*> $builder + */ + protected function addWithoutTrashed(Builder $builder): void + { + $builder->macro('withoutTrashed', function (Builder $builder) { + $model = $builder->getModel(); + + // @phpstan-ignore argument.type ($this is rebound to SoftDeletingScope when macro is called) + $builder->withoutGlobalScope($this)->whereNull( + $model->getQualifiedDeletedAtColumn() + ); + + return $builder; + }); + } + + /** + * Add the only-trashed extension to the builder. + * + * @param Builder<*> $builder + */ + protected function addOnlyTrashed(Builder $builder): void + { + $builder->macro('onlyTrashed', function (Builder $builder) { + $model = $builder->getModel(); + + // @phpstan-ignore argument.type ($this is rebound to SoftDeletingScope when macro is called) + $builder->withoutGlobalScope($this)->whereNotNull( + $model->getQualifiedDeletedAtColumn() + ); + + return $builder; + }); + } +} diff --git a/src/database/src/Events/ConnectionEstablished.php b/src/database/src/Events/ConnectionEstablished.php new file mode 100644 index 000000000..8e0456a99 --- /dev/null +++ b/src/database/src/Events/ConnectionEstablished.php @@ -0,0 +1,9 @@ +connection = $connection; + $this->connectionName = $connection->getName(); + } +} diff --git a/src/database/src/Events/DatabaseBusy.php b/src/database/src/Events/DatabaseBusy.php new file mode 100644 index 000000000..c8e73e691 --- /dev/null +++ b/src/database/src/Events/DatabaseBusy.php @@ -0,0 +1,20 @@ +method = $method; + $this->migration = $migration; + } +} diff --git a/src/database/src/Events/MigrationSkipped.php b/src/database/src/Events/MigrationSkipped.php new file mode 100644 index 000000000..5230ca43d --- /dev/null +++ b/src/database/src/Events/MigrationSkipped.php @@ -0,0 +1,20 @@ + $options the options provided when the migration method was invoked + */ + public function __construct( + public string $method, + public array $options = [], + ) { + } +} diff --git a/src/database/src/Events/MigrationsPruned.php b/src/database/src/Events/MigrationsPruned.php new file mode 100644 index 000000000..00842e497 --- /dev/null +++ b/src/database/src/Events/MigrationsPruned.php @@ -0,0 +1,35 @@ +connection = $connection; + $this->connectionName = $connection->getName(); + $this->path = $path; + } +} diff --git a/src/database/src/Events/MigrationsStarted.php b/src/database/src/Events/MigrationsStarted.php new file mode 100644 index 000000000..1fd789e20 --- /dev/null +++ b/src/database/src/Events/MigrationsStarted.php @@ -0,0 +1,9 @@ + $models the class names of the models that were pruned + */ + public function __construct( + public array $models, + ) { + } +} diff --git a/src/database/src/Events/ModelPruningStarting.php b/src/database/src/Events/ModelPruningStarting.php new file mode 100644 index 000000000..d89a02995 --- /dev/null +++ b/src/database/src/Events/ModelPruningStarting.php @@ -0,0 +1,18 @@ + $models the class names of the models that will be pruned + */ + public function __construct( + public array $models, + ) { + } +} diff --git a/src/database/src/Events/ModelsPruned.php b/src/database/src/Events/ModelsPruned.php new file mode 100644 index 000000000..03f8582f1 --- /dev/null +++ b/src/database/src/Events/ModelsPruned.php @@ -0,0 +1,20 @@ +sql = $sql; + $this->time = $time; + $this->bindings = $bindings; + $this->connection = $connection; + $this->connectionName = $connection->getName(); + $this->readWriteType = $readWriteType; + } + + /** + * Get the raw SQL representation of the query with embedded bindings. + */ + public function toRawSql(): string + { + return $this->connection + ->query() + ->getGrammar() + ->substituteBindingsIntoRawSql($this->sql, $this->connection->prepareBindings($this->bindings)); + } +} diff --git a/src/database/src/Events/SchemaDumped.php b/src/database/src/Events/SchemaDumped.php new file mode 100644 index 000000000..ead46de2c --- /dev/null +++ b/src/database/src/Events/SchemaDumped.php @@ -0,0 +1,35 @@ +connection = $connection; + $this->connectionName = $connection->getName(); + $this->path = $path; + } +} diff --git a/src/database/src/Events/SchemaLoaded.php b/src/database/src/Events/SchemaLoaded.php new file mode 100644 index 000000000..b203b43bb --- /dev/null +++ b/src/database/src/Events/SchemaLoaded.php @@ -0,0 +1,35 @@ +connection = $connection; + $this->connectionName = $connection->getName(); + $this->path = $path; + } +} diff --git a/src/database/src/Events/StatementPrepared.php b/src/database/src/Events/StatementPrepared.php new file mode 100644 index 000000000..252562026 --- /dev/null +++ b/src/database/src/Events/StatementPrepared.php @@ -0,0 +1,23 @@ +connection = $connection; + } + + /** + * Wrap an array of values. + * + * @param array $values + * @return array + */ + public function wrapArray(array $values): array + { + return array_map($this->wrap(...), $values); + } + + /** + * Wrap a table in keyword identifiers. + */ + public function wrapTable(Expression|string $table, ?string $prefix = null): string + { + if ($this->isExpression($table)) { + return $this->getValue($table); + } + + $prefix ??= $this->connection->getTablePrefix(); + + // If the table being wrapped has an alias we'll need to separate the pieces + // so we can prefix the table and then wrap each of the segments on their + // own and then join these both back together using the "as" connector. + if (stripos($table, ' as ') !== false) { + return $this->wrapAliasedTable($table, $prefix); + } + + // If the table being wrapped has a custom schema name specified, we need to + // prefix the last segment as the table name then wrap each segment alone + // and eventually join them both back together using the dot connector. + if (str_contains($table, '.')) { + $table = substr_replace($table, '.' . $prefix, strrpos($table, '.'), 1); + + return (new Collection(explode('.', $table))) + ->map($this->wrapValue(...)) + ->implode('.'); + } + + return $this->wrapValue($prefix . $table); + } + + /** + * Wrap a value in keyword identifiers. + */ + public function wrap(Expression|string $value): string + { + if ($this->isExpression($value)) { + return $this->getValue($value); + } + + // If the value being wrapped has a column alias we will need to separate out + // the pieces so we can wrap each of the segments of the expression on its + // own, and then join these both back together using the "as" connector. + if (stripos($value, ' as ') !== false) { + return $this->wrapAliasedValue($value); + } + + // If the given value is a JSON selector we will wrap it differently than a + // traditional value. We will need to split this path and wrap each part + // wrapped, etc. Otherwise, we will simply wrap the value as a string. + if ($this->isJsonSelector($value)) { + return $this->wrapJsonSelector($value); + } + + return $this->wrapSegments(explode('.', $value)); + } + + /** + * Wrap a value that has an alias. + */ + protected function wrapAliasedValue(string $value): string + { + $segments = preg_split('/\s+as\s+/i', $value); + + return $this->wrap($segments[0]) . ' as ' . $this->wrapValue($segments[1]); + } + + /** + * Wrap a table that has an alias. + */ + protected function wrapAliasedTable(string $value, ?string $prefix = null): string + { + $segments = preg_split('/\s+as\s+/i', $value); + + $prefix ??= $this->connection->getTablePrefix(); + + return $this->wrapTable($segments[0], $prefix) . ' as ' . $this->wrapValue($prefix . $segments[1]); + } + + /** + * Wrap the given value segments. + * + * @param list $segments + */ + protected function wrapSegments(array $segments): string + { + return (new Collection($segments))->map(function ($segment, $key) use ($segments) { + return $key == 0 && count($segments) > 1 + ? $this->wrapTable($segment) + : $this->wrapValue($segment); + })->implode('.'); + } + + /** + * Wrap a single string in keyword identifiers. + */ + protected function wrapValue(string $value): string + { + if ($value !== '*') { + return '"' . str_replace('"', '""', $value) . '"'; + } + + return $value; + } + + /** + * Wrap the given JSON selector. + * + * @throws RuntimeException + */ + protected function wrapJsonSelector(string $value): string + { + throw new RuntimeException('This database engine does not support JSON operations.'); + } + + /** + * Determine if the given string is a JSON selector. + */ + protected function isJsonSelector(string $value): bool + { + return str_contains($value, '->'); + } + + /** + * Convert an array of column names into a delimited string. + * + * @param array $columns + */ + public function columnize(array $columns): string + { + return implode(', ', array_map($this->wrap(...), $columns)); + } + + /** + * Create query parameter place-holders for an array. + */ + public function parameterize(array $values): string + { + return implode(', ', array_map($this->parameter(...), $values)); + } + + /** + * Get the appropriate query parameter place-holder for a value. + */ + public function parameter(mixed $value): string|int|float + { + return $this->isExpression($value) ? $this->getValue($value) : '?'; + } + + /** + * Quote the given string literal. + * + * @param array|string $value + */ + public function quoteString(string|array $value): string + { + if (is_array($value)) { + return implode(', ', array_map([$this, __FUNCTION__], $value)); + } + + return "'{$value}'"; + } + + /** + * Escapes a value for safe SQL embedding. + */ + public function escape(string|float|int|bool|null $value, bool $binary = false): string + { + return $this->connection->escape($value, $binary); + } + + /** + * Determine if the given value is a raw expression. + */ + public function isExpression(mixed $value): bool + { + return $value instanceof Expression; + } + + /** + * Transforms expressions to their scalar types. + */ + public function getValue(Expression|string|int|float $expression): string|int|float + { + if ($this->isExpression($expression)) { + return $this->getValue($expression->getValue($this)); + } + + return $expression; + } + + /** + * Get the format for database stored dates. + */ + public function getDateFormat(): string + { + return 'Y-m-d H:i:s'; + } + + /** + * Get the grammar's table prefix. + * + * @deprecated Use DB::getTablePrefix() + */ + public function getTablePrefix(): string + { + return $this->connection->getTablePrefix(); + } + + /** + * Set the grammar's table prefix. + * + * @deprecated Use DB::setTablePrefix() + */ + public function setTablePrefix(string $prefix): static + { + $this->connection->setTablePrefix($prefix); + + return $this; + } +} diff --git a/src/database/src/LazyLoadingViolationException.php b/src/database/src/LazyLoadingViolationException.php new file mode 100644 index 000000000..3858ea08f --- /dev/null +++ b/src/database/src/LazyLoadingViolationException.php @@ -0,0 +1,33 @@ +model = $class; + $this->relation = $relation; + } +} diff --git a/src/database/src/Listeners/RegisterConnectionResolverListener.php b/src/database/src/Listeners/RegisterConnectionResolverListener.php new file mode 100644 index 000000000..cbd12c6da --- /dev/null +++ b/src/database/src/Listeners/RegisterConnectionResolverListener.php @@ -0,0 +1,48 @@ +container->has(ConnectionResolverInterface::class)) { + Model::setConnectionResolver( + $this->container->get(ConnectionResolverInterface::class) + ); + } + + if ($this->container->has(Dispatcher::class)) { + Model::setEventDispatcher( + $this->container->get(Dispatcher::class) + ); + } + } +} diff --git a/src/database/src/Listeners/RegisterSQLiteConnectionListener.php b/src/database/src/Listeners/RegisterSQLiteConnectionListener.php new file mode 100644 index 000000000..39b971fbd --- /dev/null +++ b/src/database/src/Listeners/RegisterSQLiteConnectionListener.php @@ -0,0 +1,90 @@ +isInMemoryDatabase($config['database'] ?? '')) { + $connection = $this->createPersistentPdoResolver($connection, $config); + } + + return new SQLiteConnection($connection, $database, $prefix, $config); + }); + } + + /** + * Determine if the database configuration is for an in-memory database. + * + * Matches the detection logic in SQLiteConnector::parseDatabasePath(). + */ + protected function isInMemoryDatabase(string $database): bool + { + return $database === ':memory:' + || str_contains($database, '?mode=memory') + || str_contains($database, '&mode=memory'); + } + + /** + * Create a PDO resolver that returns a persistent (singleton) PDO instance. + * + * The PDO is stored in the container under a connection-specific key, + * ensuring all pooled connections for this in-memory database share + * the same underlying PDO instance. + * + * @param Closure|PDO $connection The original PDO or PDO-creating closure + * @param array $config The connection configuration + * @return Closure A closure that returns the persistent PDO + */ + protected function createPersistentPdoResolver(Closure|PDO $connection, array $config): Closure + { + return function () use ($connection, $config): PDO { + /** @var \Hyperf\Contract\ContainerInterface $container */ + $container = ApplicationContext::getContainer(); + $key = 'sqlite.persistent.pdo.' . ($config['name'] ?? 'default'); + + if (! $container->has($key)) { + $pdo = $connection instanceof Closure ? $connection() : $connection; + $container->set($key, $pdo); + } + + return $container->get($key); + }; + } +} diff --git a/src/database/src/Listeners/UnsetContextInTaskWorkerListener.php b/src/database/src/Listeners/UnsetContextInTaskWorkerListener.php new file mode 100644 index 000000000..35bc1779a --- /dev/null +++ b/src/database/src/Listeners/UnsetContextInTaskWorkerListener.php @@ -0,0 +1,50 @@ +server->taskworker) { + return; + } + + $connectionResolver = $this->container->get(ConnectionResolverInterface::class); + $databases = (array) $this->config->get('databases', []); + + foreach (array_keys($databases) as $name) { + $contextKey = (fn () => $this->getContextKey($name))->call($connectionResolver); + Context::destroy($contextKey); + } + } +} diff --git a/src/database/src/LostConnectionDetector.php b/src/database/src/LostConnectionDetector.php new file mode 100644 index 000000000..3125ec22f --- /dev/null +++ b/src/database/src/LostConnectionDetector.php @@ -0,0 +1,94 @@ +getMessage(); + + return Str::contains($message, [ + 'server has gone away', + 'Server has gone away', + 'no connection to the server', + 'Lost connection', + 'is dead or not enabled', + 'Error while sending', + 'decryption failed or bad record mac', + 'server closed the connection unexpectedly', + 'SSL connection has been closed unexpectedly', + 'Error writing data to the connection', + 'Resource deadlock avoided', + 'Transaction() on null', + 'child connection forced to terminate due to client_idle_limit', + 'query_wait_timeout', + 'reset by peer', + 'Physical connection is not usable', + 'TCP Provider: Error code 0x68', + 'ORA-03114', + 'Packets out of order. Expected', + 'Adaptive Server connection failed', + 'Communication link failure', + 'connection is no longer usable', + 'Login timeout expired', + 'SQLSTATE[HY000] [2002] Connection refused', + 'running with the --read-only option so it cannot execute this statement', + 'The connection is broken and recovery is not possible. The connection is marked by the client driver as unrecoverable. No attempt was made to restore the connection.', + 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Try again', + 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Name or service not known', + 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo for', + 'SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: EOF detected', + 'SSL error: unexpected eof', + 'SQLSTATE[HY000] [2002] Connection timed out', + 'SSL: Connection timed out', + 'SQLSTATE[HY000]: General error: 1105 The last transaction was aborted due to Seamless Scaling. Please retry.', + 'Temporary failure in name resolution', + 'SQLSTATE[08S01]: Communication link failure', + 'SQLSTATE[08006] [7] could not connect to server: Connection refused Is the server running on host', + 'SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: No route to host', + 'The client was disconnected by the server because of inactivity. See wait_timeout and interactive_timeout for configuring this behavior.', + 'SQLSTATE[08006] [7] could not translate host name', + 'TCP Provider: Error code 0x274C', + 'SQLSTATE[HY000] [2002] No such file or directory', + 'SSL: Operation timed out', + 'Reason: Server is in script upgrade mode. Only administrator can connect at this time.', + 'Unknown $curl_error_code: 77', + 'SSL: Handshake timed out', + 'SSL error: sslv3 alert unexpected message', + 'unrecognized SSL error code:', + 'SQLSTATE[HY000] [1045] Access denied for user', + 'SQLSTATE[HY000] [2002] No connection could be made because the target machine actively refused it', + 'SQLSTATE[HY000] [2002] A connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond', + 'SQLSTATE[HY000] [2002] Network is unreachable', + 'SQLSTATE[HY000] [2002] The requested address is not valid in its context', + 'SQLSTATE[HY000] [2002] A socket operation was attempted to an unreachable network', + 'SQLSTATE[HY000] [2002] Operation now in progress', + 'SQLSTATE[HY000] [2002] Operation in progress', + 'SQLSTATE[HY000]: General error: 3989', + 'went away', + 'No such file or directory', + 'server is shutting down', + 'failed to connect to', + 'Channel connection is closed', + 'Connection lost', + 'Broken pipe', + 'SQLSTATE[25006]: Read only sql transaction: 7', + 'vtgate connection error: no healthy endpoints', + 'primary is not serving, there may be a reparent operation in progress', + 'current keyspace is being resharded', + 'no healthy tablet available', + 'transaction pool connection limit exceeded', + 'SSL operation failed with code 5', + ]); + } +} diff --git a/src/database/src/LostConnectionException.php b/src/database/src/LostConnectionException.php new file mode 100644 index 000000000..53a42fcef --- /dev/null +++ b/src/database/src/LostConnectionException.php @@ -0,0 +1,11 @@ +schemaGrammar)) { + $this->useDefaultSchemaGrammar(); + } + + return new MariaDbBuilder($this); + } + + /** + * Get the default schema grammar instance. + */ + protected function getDefaultSchemaGrammar(): MariaDbSchemaGrammar + { + return new MariaDbSchemaGrammar($this); + } + + /** + * Get the schema state for the connection. + */ + #[Override] + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null): MariaDbSchemaState + { + return new MariaDbSchemaState($this, $files, $processFactory); + } + + /** + * Get the default post processor instance. + */ + protected function getDefaultPostProcessor(): MariaDbProcessor + { + return new MariaDbProcessor(); + } +} diff --git a/src/database/src/Migrations/DatabaseMigrationRepository.php b/src/database/src/Migrations/DatabaseMigrationRepository.php new file mode 100755 index 000000000..156e9ec95 --- /dev/null +++ b/src/database/src/Migrations/DatabaseMigrationRepository.php @@ -0,0 +1,187 @@ +table() + ->orderBy('batch', 'asc') + ->orderBy('migration', 'asc') + ->pluck('migration')->all(); + } + + /** + * Get the list of migrations. + */ + public function getMigrations(int $steps): array + { + $query = $this->table()->where('batch', '>=', '1'); + + return $query->orderBy('batch', 'desc') + ->orderBy('migration', 'desc') + ->limit($steps) + ->get() + ->all(); + } + + /** + * Get the list of the migrations by batch number. + */ + public function getMigrationsByBatch(int $batch): array + { + return $this->table() + ->where('batch', $batch) + ->orderBy('migration', 'desc') + ->get() + ->all(); + } + + /** + * Get the last migration batch. + */ + public function getLast(): array + { + $query = $this->table()->where('batch', $this->getLastBatchNumber()); + + return $query->orderBy('migration', 'desc')->get()->all(); + } + + /** + * Get the completed migrations with their batch numbers. + */ + public function getMigrationBatches(): array + { + return $this->table() + ->orderBy('batch', 'asc') + ->orderBy('migration', 'asc') + ->pluck('batch', 'migration')->all(); + } + + /** + * Log that a migration was run. + */ + public function log(string $file, int $batch): void + { + $record = ['migration' => $file, 'batch' => $batch]; + + $this->table()->insert($record); + } + + /** + * Remove a migration from the log. + */ + public function delete(object $migration): void + { + $this->table()->where('migration', $migration->migration)->delete(); + } + + /** + * Get the next migration batch number. + */ + public function getNextBatchNumber(): int + { + return $this->getLastBatchNumber() + 1; + } + + /** + * Get the last migration batch number. + */ + public function getLastBatchNumber(): int + { + return $this->table()->max('batch') ?? 0; + } + + /** + * Create the migration repository data store. + */ + public function createRepository(): void + { + $schema = $this->getConnection()->getSchemaBuilder(); + + $schema->create($this->table, function ($table) { + // The migrations table is responsible for keeping track of which of the + // migrations have actually run for the application. We'll create the + // table to hold the migration file's path as well as the batch ID. + $table->increments('id'); + $table->string('migration'); + $table->integer('batch'); + }); + } + + /** + * Determine if the migration repository exists. + */ + public function repositoryExists(): bool + { + $schema = $this->getConnection()->getSchemaBuilder(); + + return $schema->hasTable($this->table); + } + + /** + * Delete the migration repository data store. + */ + public function deleteRepository(): void + { + $schema = $this->getConnection()->getSchemaBuilder(); + + $schema->drop($this->table); + } + + /** + * Get a query builder for the migration table. + */ + protected function table(): Builder + { + return $this->getConnection()->table($this->table)->useWritePdo(); + } + + /** + * Get the connection resolver instance. + */ + public function getConnectionResolver(): Resolver + { + return $this->resolver; + } + + /** + * Resolve the database connection instance. + */ + public function getConnection(): ConnectionInterface + { + return $this->resolver->connection($this->connection); + } + + /** + * Set the information source to gather data. + */ + public function setSource(?string $name): void + { + $this->connection = $name; + } +} diff --git a/src/database/src/Migrations/DatabaseMigrationRepositoryFactory.php b/src/database/src/Migrations/DatabaseMigrationRepositoryFactory.php new file mode 100644 index 000000000..d540bab11 --- /dev/null +++ b/src/database/src/Migrations/DatabaseMigrationRepositoryFactory.php @@ -0,0 +1,28 @@ +get(ConfigInterface::class); + + $migrations = $config->get('database.migrations', 'migrations'); + + $table = is_array($migrations) + ? ($migrations['table'] ?? 'migrations') + : $migrations; + + return new DatabaseMigrationRepository( + $container->get(ConnectionResolverInterface::class), + $table + ); + } +} diff --git a/src/core/src/Database/Migrations/Migration.php b/src/database/src/Migrations/Migration.php similarity index 53% rename from src/core/src/Database/Migrations/Migration.php rename to src/database/src/Migrations/Migration.php index dc1507af0..0f062e6f5 100644 --- a/src/core/src/Database/Migrations/Migration.php +++ b/src/database/src/Migrations/Migration.php @@ -4,32 +4,31 @@ namespace Hypervel\Database\Migrations; -use Hyperf\Database\ConnectionResolverInterface; -use Hypervel\Context\ApplicationContext; - abstract class Migration { /** - * Enables, if supported, wrapping the migration within a transaction. + * The name of the database connection to use. */ - public bool $withinTransaction = true; + protected ?string $connection = null; /** - * The name of the database connection to use. + * Enables, if supported, wrapping the migration within a transaction. */ - protected ?string $connection = null; + public bool $withinTransaction = true; /** * Get the migration connection name. */ - public function getConnection(): string + public function getConnection(): ?string { - if ($connection = $this->connection) { - return $connection; - } + return $this->connection; + } - return ApplicationContext::getContainer() - ->get(ConnectionResolverInterface::class) - ->getDefaultConnection(); + /** + * Determine if this migration should run. + */ + public function shouldRun(): bool + { + return true; } } diff --git a/src/database/src/Migrations/MigrationCreator.php b/src/database/src/Migrations/MigrationCreator.php new file mode 100644 index 000000000..521fd34a2 --- /dev/null +++ b/src/database/src/Migrations/MigrationCreator.php @@ -0,0 +1,178 @@ +ensureMigrationDoesntAlreadyExist($name, $path); + + // First we will get the stub file for the migration, which serves as a type + // of template for the migration. Once we have those we will populate the + // various place-holders, save the file, and run the post create event. + $stub = $this->getStub($table, $create); + + $path = $this->getPath($name, $path); + + $this->files->ensureDirectoryExists(dirname($path)); + + $this->files->put( + $path, + $this->populateStub($stub, $table) + ); + + // Next, we will fire any hooks that are supposed to fire after a migration is + // created. Once that is done we'll be ready to return the full path to the + // migration file so it can be used however it's needed by the developer. + $this->firePostCreateHooks($table, $path); + + return $path; + } + + /** + * Ensure that a migration with the given name doesn't already exist. + * + * @throws InvalidArgumentException + */ + protected function ensureMigrationDoesntAlreadyExist(string $name, ?string $migrationPath = null): void + { + if (! empty($migrationPath)) { + $migrationFiles = $this->files->glob($migrationPath . '/*.php'); + + foreach ($migrationFiles as $migrationFile) { + $this->files->requireOnce($migrationFile); + } + } + + if (class_exists($className = $this->getClassName($name))) { + throw new InvalidArgumentException("A {$className} class already exists."); + } + } + + /** + * Get the migration stub file. + */ + protected function getStub(?string $table, bool $create): string + { + if (is_null($table)) { + $stub = $this->files->exists($customPath = $this->customStubPath . '/migration.stub') + ? $customPath + : $this->stubPath() . '/migration.stub'; + } elseif ($create) { + $stub = $this->files->exists($customPath = $this->customStubPath . '/migration.create.stub') + ? $customPath + : $this->stubPath() . '/migration.create.stub'; + } else { + $stub = $this->files->exists($customPath = $this->customStubPath . '/migration.update.stub') + ? $customPath + : $this->stubPath() . '/migration.update.stub'; + } + + return $this->files->get($stub); + } + + /** + * Populate the place-holders in the migration stub. + */ + protected function populateStub(string $stub, ?string $table): string + { + // Here we will replace the table place-holders with the table specified by + // the developer, which is useful for quickly creating a tables creation + // or update migration from the console instead of typing it manually. + if (! is_null($table)) { + $stub = str_replace( + ['DummyTable', '{{ table }}', '{{table}}'], + $table, + $stub + ); + } + + return $stub; + } + + /** + * Get the class name of a migration name. + */ + protected function getClassName(string $name): string + { + return Str::studly($name); + } + + /** + * Get the full path to the migration. + */ + protected function getPath(string $name, string $path): string + { + return $path . '/' . $this->getDatePrefix() . '_' . $name . '.php'; + } + + /** + * Fire the registered post create hooks. + */ + protected function firePostCreateHooks(?string $table, string $path): void + { + foreach ($this->postCreate as $callback) { + $callback($table, $path); + } + } + + /** + * Register a post migration create hook. + */ + public function afterCreate(Closure $callback): void + { + $this->postCreate[] = $callback; + } + + /** + * Get the date prefix for the migration. + */ + protected function getDatePrefix(): string + { + return date('Y_m_d_His'); + } + + /** + * Get the path to the stubs. + */ + public function stubPath(): string + { + return __DIR__ . '/stubs'; + } + + /** + * Get the filesystem instance. + */ + public function getFilesystem(): Filesystem + { + return $this->files; + } +} diff --git a/src/database/src/Migrations/MigrationRepositoryInterface.php b/src/database/src/Migrations/MigrationRepositoryInterface.php new file mode 100755 index 000000000..147941a14 --- /dev/null +++ b/src/database/src/Migrations/MigrationRepositoryInterface.php @@ -0,0 +1,68 @@ + + */ + protected static array $requiredPathCache = []; + + /** + * The output interface implementation. + */ + protected ?OutputInterface $output = null; + + /** + * The pending migrations to skip. + * + * @var list + */ + protected static array $withoutMigrations = []; + + /** + * Create a new migrator instance. + */ + public function __construct( + protected MigrationRepositoryInterface $repository, + protected Resolver $resolver, + protected Filesystem $files, + protected ?Dispatcher $events = null, + ) { + } + + /** + * Run the pending migrations at a given path. + * + * @param string|string[] $paths + * @param array $options + * @return string[] + */ + public function run(array|string $paths = [], array $options = []): array + { + // Once we grab all of the migration files for the path, we will compare them + // against the migrations that have already been run for this package then + // run each of the outstanding migrations against a database connection. + $files = $this->getMigrationFiles($paths); + + $this->requireFiles($migrations = $this->pendingMigrations( + $files, + $this->repository->getRan() + )); + + // Once we have all these migrations that are outstanding we are ready to run + // we will go ahead and run them "up". This will execute each migration as + // an operation against a database. Then we'll return this list of them. + $this->runPending($migrations, $options); + + return $migrations; + } + + /** + * Get the migration files that have not yet run. + * + * @param string[] $files + * @param string[] $ran + * @return string[] + */ + protected function pendingMigrations(array $files, array $ran): array + { + $migrationsToSkip = $this->migrationsToSkip(); + + return (new Collection($files)) + ->reject( + fn ($file) => in_array($migrationName = $this->getMigrationName($file), $ran) + || in_array($migrationName, $migrationsToSkip) + ) + ->values() + ->all(); + } + + /** + * Get list of pending migrations to skip. + * + * @return list + */ + protected function migrationsToSkip(): array + { + return (new Collection(self::$withoutMigrations)) + ->map($this->getMigrationName(...)) + ->all(); + } + + /** + * Run an array of migrations. + * + * @param string[] $migrations + * @param array $options + */ + public function runPending(array $migrations, array $options = []): void + { + // First we will just make sure that there are any migrations to run. If there + // aren't, we will just make a note of it to the developer so they're aware + // that all of the migrations have been run against this database system. + if (count($migrations) === 0) { + $this->fireMigrationEvent(new NoPendingMigrations('up')); + + $this->write(Info::class, 'Nothing to migrate'); + + return; + } + + // Next, we will get the next batch number for the migrations so we can insert + // correct batch number in the database migrations repository when we store + // each migration's execution. We will also extract a few of the options. + $batch = $this->repository->getNextBatchNumber(); + + $pretend = $options['pretend'] ?? false; + + $step = $options['step'] ?? false; + + $this->fireMigrationEvent(new MigrationsStarted('up', $options)); + + $this->write(Info::class, 'Running migrations.'); + + // Once we have the array of migrations, we will spin through them and run the + // migrations "up" so the changes are made to the databases. We'll then log + // that the migration was run so we don't repeat it next time we execute. + foreach ($migrations as $file) { + $this->runUp($file, $batch, $pretend); + + if ($step) { + ++$batch; + } + } + + $this->fireMigrationEvent(new MigrationsEnded('up', $options)); + + $this->output?->writeln(''); + } + + /** + * Run "up" a migration instance. + */ + protected function runUp(string $file, int $batch, bool $pretend): void + { + // First we will resolve a "real" instance of the migration class from this + // migration file name. Once we have the instances we can run the actual + // command such as "up" or "down", or we can just simulate the action. + $migration = $this->resolvePath($file); + + $name = $this->getMigrationName($file); + + if ($pretend) { + $this->pretendToRun($migration, 'up'); + + return; + } + + $shouldRunMigration = $migration instanceof Migration + ? $migration->shouldRun() + : true; + + if (! $shouldRunMigration) { + $this->fireMigrationEvent(new MigrationSkipped($name)); + + $this->write(Task::class, $name, fn () => MigrationResult::Skipped->value); + } else { + $this->write(Task::class, $name, fn () => $this->runMigration($migration, 'up')); + + // Once we have run a migrations class, we will log that it was run in this + // repository so that we don't try to run it next time we do a migration + // in the application. A migration repository keeps the migrate order. + $this->repository->log($name, $batch); + } + } + + /** + * Rollback the last migration operation. + * + * @param string|string[] $paths + * @param array $options + * @return string[] + */ + public function rollback(array|string $paths = [], array $options = []): array + { + // We want to pull in the last batch of migrations that ran on the previous + // migration operation. We'll then reverse those migrations and run each + // of them "down" to reverse the last migration "operation" which ran. + $migrations = $this->getMigrationsForRollback($options); + + if (count($migrations) === 0) { + $this->fireMigrationEvent(new NoPendingMigrations('down')); + + $this->write(Info::class, 'Nothing to rollback.'); + + return []; + } + + return tap($this->rollbackMigrations($migrations, $paths, $options), function () { + $this->output?->writeln(''); + }); + } + + /** + * Get the migrations for a rollback operation. + * + * @param array $options + */ + protected function getMigrationsForRollback(array $options): array + { + if (($steps = $options['step'] ?? 0) > 0) { + return $this->repository->getMigrations($steps); + } + + if (($batch = $options['batch'] ?? 0) > 0) { + return $this->repository->getMigrationsByBatch($batch); + } + + return $this->repository->getLast(); + } + + /** + * Rollback the given migrations. + * + * @param string|string[] $paths + * @param array $options + * @return string[] + */ + protected function rollbackMigrations(array $migrations, array|string $paths, array $options): array + { + $rolledBack = []; + + $this->requireFiles($files = $this->getMigrationFiles($paths)); + + $this->fireMigrationEvent(new MigrationsStarted('down', $options)); + + $this->write(Info::class, 'Rolling back migrations.'); + + // Next we will run through all of the migrations and call the "down" method + // which will reverse each migration in order. This getLast method on the + // repository already returns these migration's names in reverse order. + foreach ($migrations as $migration) { + $migration = (object) $migration; + + if (! $file = Arr::get($files, $migration->migration)) { + $this->write(TwoColumnDetail::class, $migration->migration, 'Migration not found'); + + continue; + } + + $rolledBack[] = $file; + + $this->runDown( + $file, + $migration, + $options['pretend'] ?? false + ); + } + + $this->fireMigrationEvent(new MigrationsEnded('down', $options)); + + return $rolledBack; + } + + /** + * Rolls all of the currently applied migrations back. + * + * @param string|string[] $paths + */ + public function reset(array|string $paths = [], bool $pretend = false): array + { + // Next, we will reverse the migration list so we can run them back in the + // correct order for resetting this database. This will allow us to get + // the database back into its "empty" state ready for the migrations. + $migrations = array_reverse($this->repository->getRan()); + + if (count($migrations) === 0) { + $this->write(Info::class, 'Nothing to rollback.'); + + return []; + } + + return tap($this->resetMigrations($migrations, Arr::wrap($paths), $pretend), function () { + $this->output?->writeln(''); + }); + } + + /** + * Reset the given migrations. + * + * @param string[] $migrations + * @param string[] $paths + */ + protected function resetMigrations(array $migrations, array $paths, bool $pretend = false): array + { + // Since the getRan method that retrieves the migration name just gives us the + // migration name, we will format the names into objects with the name as a + // property on the objects so that we can pass it to the rollback method. + $migrations = (new Collection($migrations))->map(fn ($m) => (object) ['migration' => $m])->all(); + + return $this->rollbackMigrations( + $migrations, + $paths, + compact('pretend') + ); + } + + /** + * Run "down" a migration instance. + */ + protected function runDown(string $file, object $migration, bool $pretend): void + { + // First we will get the file name of the migration so we can resolve out an + // instance of the migration. Once we get an instance we can either run a + // pretend execution of the migration or we can run the real migration. + $instance = $this->resolvePath($file); + + $name = $this->getMigrationName($file); + + if ($pretend) { + $this->pretendToRun($instance, 'down'); + + return; + } + + $this->write(Task::class, $name, fn () => $this->runMigration($instance, 'down')); + + // Once we have successfully run the migration "down" we will remove it from + // the migration repository so it will be considered to have not been run + // by the application then will be able to fire by any later operation. + $this->repository->delete($migration); + } + + /** + * Run a migration inside a transaction if the database supports it. + */ + protected function runMigration(object $migration, string $method): void + { + $connection = $this->resolveConnection( + $migration->getConnection() + ); + + $callback = function () use ($connection, $migration, $method) { + if (method_exists($migration, $method)) { + $this->fireMigrationEvent(new MigrationStarted($migration, $method)); + + $this->runMethod($connection, $migration, $method); + + $this->fireMigrationEvent(new MigrationEnded($migration, $method)); + } + }; + + $this->getSchemaGrammar($connection)->supportsSchemaTransactions() + && $migration->withinTransaction + ? $connection->transaction($callback) + : $callback(); + } + + /** + * Pretend to run the migrations. + */ + protected function pretendToRun(object $migration, string $method): void + { + $name = get_class($migration); + + $reflectionClass = new ReflectionClass($migration); + + if ($reflectionClass->isAnonymous()) { + $name = $this->getMigrationName($reflectionClass->getFileName()); + } + + $this->write(TwoColumnDetail::class, $name); + + $this->write( + BulletList::class, + (new Collection($this->getQueries($migration, $method)))->map(fn ($query) => $query['query']) + ); + } + + /** + * Get all of the queries that would be run for a migration. + */ + protected function getQueries(object $migration, string $method): array + { + // Now that we have the connections we can resolve it and pretend to run the + // queries against the database returning the array of raw SQL statements + // that would get fired against the database system for this migration. + $db = $this->resolveConnection( + $migration->getConnection() + ); + + return $db->pretend(function () use ($db, $migration, $method) { + if (method_exists($migration, $method)) { + $this->runMethod($db, $migration, $method); + } + }); + } + + /** + * Run a migration method on the given connection. + */ + protected function runMethod(Connection $connection, object $migration, string $method): void + { + $previousConnection = $this->resolver->getDefaultConnection(); + + try { + $this->resolver->setDefaultConnection($connection->getName()); + + $migration->{$method}(); + } finally { + $this->resolver->setDefaultConnection($previousConnection); + } + } + + /** + * Resolve a migration instance from a file. + */ + public function resolve(string $file): object + { + $class = $this->getMigrationClass($file); + + return new $class(); + } + + /** + * Resolve a migration instance from a migration path. + */ + protected function resolvePath(string $path): object + { + $class = $this->getMigrationClass($this->getMigrationName($path)); + + if (class_exists($class) && realpath($path) == (new ReflectionClass($class))->getFileName()) { + return new $class(); + } + + $migration = static::$requiredPathCache[$path] ??= $this->files->getRequire($path); + + if (is_object($migration)) { + return method_exists($migration, '__construct') + ? $this->files->getRequire($path) + : clone $migration; + } + + return new $class(); + } + + /** + * Generate a migration class name based on the migration file name. + */ + protected function getMigrationClass(string $migrationName): string + { + return Str::studly(implode('_', array_slice(explode('_', $migrationName), 4))); + } + + /** + * Get all of the migration files in a given path. + * + * @return array + */ + public function getMigrationFiles(array|string $paths): array + { + return (new Collection($paths)) + ->flatMap(fn ($path) => str_ends_with($path, '.php') ? [$path] : $this->files->glob($path . '/*_*.php')) + ->filter() + ->values() + ->keyBy(fn ($file) => $this->getMigrationName($file)) + ->sortBy(fn ($file, $key) => $key) + ->all(); + } + + /** + * Require in all the migration files in a given path. + * + * @param string[] $files + */ + public function requireFiles(array $files): void + { + foreach ($files as $file) { + $this->files->requireOnce($file); + } + } + + /** + * Get the name of the migration. + */ + public function getMigrationName(string $path): string + { + return str_replace('.php', '', basename($path)); + } + + /** + * Register a custom migration path. + */ + public function path(string $path): void + { + $this->paths = array_unique(array_merge($this->paths, [$path])); + } + + /** + * Get all of the custom migration paths. + * + * @return string[] + */ + public function paths(): array + { + return $this->paths; + } + + /** + * Set the pending migrations to skip. + * + * @param list $migrations + */ + public static function withoutMigrations(array $migrations): void + { + static::$withoutMigrations = $migrations; + } + + /** + * Get the default connection name. + */ + public function getConnection(): ?string + { + return $this->connection; + } + + /** + * Execute the given callback using the given connection as the default connection. + */ + public function usingConnection(?string $name, callable $callback): mixed + { + $previousConnection = $this->resolver->getDefaultConnection(); + + $this->setConnection($name); + + try { + return $callback(); + } finally { + $this->setConnection($previousConnection); + } + } + + /** + * Set the default connection name. + */ + public function setConnection(?string $name): void + { + if (! is_null($name)) { + $this->resolver->setDefaultConnection($name); + } + + $this->repository->setSource($name); + + $this->connection = $name; + } + + /** + * Resolve the database connection instance. + */ + public function resolveConnection(?string $connection): Connection + { + if (static::$connectionResolverCallback) { + return call_user_func( + static::$connectionResolverCallback, + $this->resolver, + $connection ?: $this->connection + ); + } + // @phpstan-ignore return.type (resolver returns ConnectionInterface but concrete Connection in practice) + return $this->resolver->connection($connection ?: $this->connection); + } + + /** + * Set a connection resolver callback. + */ + public static function resolveConnectionsUsing(Closure $callback): void + { + static::$connectionResolverCallback = $callback; + } + + /** + * Get the schema grammar out of a migration connection. + */ + protected function getSchemaGrammar(Connection $connection): SchemaGrammar + { + if (is_null($grammar = $connection->getSchemaGrammar())) { + $connection->useDefaultSchemaGrammar(); + + $grammar = $connection->getSchemaGrammar(); + } + + return $grammar; + } + + /** + * Get the migration repository instance. + */ + public function getRepository(): MigrationRepositoryInterface + { + return $this->repository; + } + + /** + * Determine if the migration repository exists. + */ + public function repositoryExists(): bool + { + return $this->repository->repositoryExists(); + } + + /** + * Determine if any migrations have been run. + */ + public function hasRunAnyMigrations(): bool + { + return $this->repositoryExists() && count($this->repository->getRan()) > 0; + } + + /** + * Delete the migration repository data store. + */ + public function deleteRepository(): void + { + $this->repository->deleteRepository(); + } + + /** + * Get the file system instance. + */ + public function getFilesystem(): Filesystem + { + return $this->files; + } + + /** + * Set the output implementation that should be used by the console. + */ + public function setOutput(OutputInterface $output): static + { + $this->output = $output; + + return $this; + } + + /** + * Write to the console's output. + * + * @param class-string $component + */ + protected function write(string $component, mixed ...$arguments): void + { + if ($this->output) { + (new $component($this->output))->render(...$arguments); + } else { + // Still execute callbacks when there's no output (e.g., running programmatically) + foreach ($arguments as $argument) { + if (is_callable($argument)) { + $argument(); + } + } + } + } + + /** + * Fire the given event for the migration. + */ + public function fireMigrationEvent(MigrationEventContract $event): void + { + $this->events?->dispatch($event); + } +} diff --git a/src/core/src/Database/Migrations/stubs/create.stub b/src/database/src/Migrations/stubs/migration.create.stub similarity index 60% rename from src/core/src/Database/Migrations/stubs/create.stub rename to src/database/src/Migrations/stubs/migration.create.stub index a5c7a6850..a2792d3b3 100644 --- a/src/core/src/Database/Migrations/stubs/create.stub +++ b/src/database/src/Migrations/stubs/migration.create.stub @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration @@ -13,9 +13,9 @@ return new class extends Migration */ public function up(): void { - Schema::create('DummyTable', function (Blueprint $table) { - $table->bigIncrements('id'); - $table->datetimes(); + Schema::create('{{ table }}', function (Blueprint $table) { + $table->id(); + $table->timestamps(); }); } @@ -24,6 +24,6 @@ return new class extends Migration */ public function down(): void { - Schema::dropIfExists('DummyTable'); + Schema::dropIfExists('{{ table }}'); } -}; \ No newline at end of file +}; diff --git a/src/core/src/Database/Migrations/stubs/blank.stub b/src/database/src/Migrations/stubs/migration.stub similarity index 89% rename from src/core/src/Database/Migrations/stubs/blank.stub rename to src/database/src/Migrations/stubs/migration.stub index 5a04dac6e..3c56b794c 100644 --- a/src/core/src/Database/Migrations/stubs/blank.stub +++ b/src/database/src/Migrations/stubs/migration.stub @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration @@ -23,4 +23,4 @@ return new class extends Migration { // } -}; \ No newline at end of file +}; diff --git a/src/core/src/Database/Migrations/stubs/update.stub b/src/database/src/Migrations/stubs/migration.update.stub similarity index 68% rename from src/core/src/Database/Migrations/stubs/update.stub rename to src/database/src/Migrations/stubs/migration.update.stub index c11bd1341..0a333f627 100644 --- a/src/core/src/Database/Migrations/stubs/update.stub +++ b/src/database/src/Migrations/stubs/migration.update.stub @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration @@ -13,7 +13,7 @@ return new class extends Migration */ public function up(): void { - Schema::table('DummyTable', function (Blueprint $table) { + Schema::table('{{ table }}', function (Blueprint $table) { // }); } @@ -23,8 +23,8 @@ return new class extends Migration */ public function down(): void { - Schema::table('DummyTable', function (Blueprint $table) { + Schema::table('{{ table }}', function (Blueprint $table) { // }); } -}; \ No newline at end of file +}; diff --git a/src/core/src/Database/ModelIdentifier.php b/src/database/src/ModelIdentifier.php similarity index 72% rename from src/core/src/Database/ModelIdentifier.php rename to src/database/src/ModelIdentifier.php index 10ed61608..a74bb5daf 100644 --- a/src/core/src/Database/ModelIdentifier.php +++ b/src/database/src/ModelIdentifier.php @@ -8,27 +8,31 @@ class ModelIdentifier { /** * The class name of the model collection. + * + * @var null|class-string */ - public ?string $collectionClass; + public ?string $collectionClass = null; /** * Create a new model identifier. * - * @param string $class the class name of the model + * @param class-string $class * @param mixed $id this may be either a single ID or an array of IDs * @param array $relations the relationships loaded on the model - * @param mixed $connection the connection name of the model + * @param null|string $connection the connection name of the model */ public function __construct( public string $class, public mixed $id, public array $relations, - public mixed $connection = null + public ?string $connection = null ) { } /** * Specify the collection class that should be used when serializing / restoring collections. + * + * @param null|class-string $collectionClass */ public function useCollectionClass(?string $collectionClass): static { diff --git a/src/database/src/MultipleColumnsSelectedException.php b/src/database/src/MultipleColumnsSelectedException.php new file mode 100644 index 000000000..7d6606910 --- /dev/null +++ b/src/database/src/MultipleColumnsSelectedException.php @@ -0,0 +1,11 @@ +count = $count; + + parent::__construct("{$count} records were found.", $code, $previous); + } + + /** + * Get the number of records found. + */ + public function getCount(): int + { + return $this->count; + } +} diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php new file mode 100755 index 000000000..a9043a19f --- /dev/null +++ b/src/database/src/MySqlConnection.php @@ -0,0 +1,145 @@ +isMaria() ? 'MariaDB' : 'MySQL'; + } + + /** + * Run an insert statement against the database. + */ + public function insert(string $query, array $bindings = [], ?string $sequence = null): bool + { + return $this->run($query, $bindings, function ($query, $bindings) use ($sequence) { + if ($this->pretending()) { + return true; + } + + $statement = $this->getPdo()->prepare($query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $this->recordsHaveBeenModified(); + + $result = $statement->execute(); + + $this->lastInsertId = $this->getPdo()->lastInsertId($sequence); + + return $result; + }); + } + + /** + * Escape a binary value for safe SQL embedding. + */ + protected function escapeBinary(string $value): string + { + $hex = bin2hex($value); + + return "x'{$hex}'"; + } + + /** + * Determine if the given database exception was caused by a unique constraint violation. + */ + protected function isUniqueConstraintError(Exception $exception): bool + { + return (bool) preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage()); + } + + /** + * Get the connection's last insert ID. + */ + public function getLastInsertId(): string|int|null + { + return $this->lastInsertId; + } + + /** + * Determine if the connected database is a MariaDB database. + */ + public function isMaria(): bool + { + return str_contains($this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), 'MariaDB'); + } + + /** + * Get the server version for the connection. + */ + public function getServerVersion(): string + { + return str_contains($version = parent::getServerVersion(), 'MariaDB') + ? Str::between($version, '5.5.5-', '-MariaDB') + : $version; + } + + /** + * Get the default query grammar instance. + */ + protected function getDefaultQueryGrammar(): MySqlGrammar + { + return new MySqlGrammar($this); + } + + /** + * Get a schema builder instance for the connection. + */ + public function getSchemaBuilder(): MySqlBuilder + { + if (is_null($this->schemaGrammar)) { + $this->useDefaultSchemaGrammar(); + } + + return new MySqlBuilder($this); + } + + /** + * Get the default schema grammar instance. + */ + protected function getDefaultSchemaGrammar(): MySqlSchemaGrammar + { + return new MySqlSchemaGrammar($this); + } + + /** + * Get the schema state for the connection. + */ + #[Override] + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null): MySqlSchemaState + { + return new MySqlSchemaState($this, $files, $processFactory); + } + + /** + * Get the default post processor instance. + */ + protected function getDefaultPostProcessor(): MySqlProcessor + { + return new MySqlProcessor(); + } +} diff --git a/src/database/src/Pool/DbPool.php b/src/database/src/Pool/DbPool.php new file mode 100644 index 000000000..b67d08771 --- /dev/null +++ b/src/database/src/Pool/DbPool.php @@ -0,0 +1,63 @@ +get(ConfigInterface::class); + $key = sprintf('databases.%s', $this->name); + + if (! $configService->has($key)) { + throw new InvalidArgumentException(sprintf('Database connection [%s] not configured.', $this->name)); + } + + // Include the connection name in the config + $this->config = $configService->get($key); + $this->config['name'] = $name; + + // Extract pool options + $poolOptions = Arr::get($this->config, 'pool', []); + + $this->frequency = new Frequency($this); + + parent::__construct($container, $poolOptions); + } + + /** + * Get the pool name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Create a new pooled connection. + */ + protected function createConnection(): ConnectionInterface + { + return new PooledConnection($this->container, $this, $this->config); + } +} diff --git a/src/database/src/Pool/PoolFactory.php b/src/database/src/Pool/PoolFactory.php new file mode 100644 index 000000000..498524cd2 --- /dev/null +++ b/src/database/src/Pool/PoolFactory.php @@ -0,0 +1,75 @@ + + */ + protected array $pools = []; + + public function __construct( + protected ContainerInterface $container + ) { + } + + /** + * Get or create a pool for the given connection name. + */ + public function getPool(string $name): DbPool + { + if (isset($this->pools[$name])) { + return $this->pools[$name]; + } + + if ($this->container instanceof Container) { + $pool = $this->container->make(DbPool::class, ['name' => $name]); + } else { + $pool = new DbPool($this->container, $name); + } + + return $this->pools[$name] = $pool; + } + + /** + * Check if a pool exists for the given connection name. + */ + public function hasPool(string $name): bool + { + return isset($this->pools[$name]); + } + + /** + * Flush a specific pool, closing all connections. + */ + public function flushPool(string $name): void + { + if (isset($this->pools[$name])) { + $this->pools[$name]->flushAll(); + unset($this->pools[$name]); + } + } + + /** + * Flush all pools, closing all connections. + */ + public function flushAll(): void + { + foreach ($this->pools as $pool) { + $pool->flushAll(); + } + + $this->pools = []; + } +} diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php new file mode 100644 index 000000000..5c0c3c5d4 --- /dev/null +++ b/src/database/src/Pool/PooledConnection.php @@ -0,0 +1,231 @@ +factory = $container->get(ConnectionFactory::class); + $this->logger = $container->get(StdoutLoggerInterface::class); + + if ($container->has(EventDispatcherInterface::class)) { + $this->dispatcher = $container->get(EventDispatcherInterface::class); + } + + $this->reconnect(); + } + + /** + * Get the underlying database connection. + */ + public function getConnection(): Connection + { + try { + return $this->getActiveConnection(); + } catch (Throwable $exception) { + $this->logger->warning('Get connection failed, try again. ' . $exception); + return $this->getActiveConnection(); + } + } + + /** + * Get the active connection, reconnecting if necessary. + */ + public function getActiveConnection(): Connection + { + if ($this->check()) { + return $this->connection; + } + + if (! $this->reconnect()) { + throw new RuntimeException('Database connection reconnect failed.'); + } + + return $this->connection; + } + + /** + * Reconnect to the database. + */ + public function reconnect(): bool + { + $this->close(); + + $this->connection = $this->factory->make($this->config, $this->config['name'] ?? null); + + // Configure event dispatcher for query events + if ($this->container->has(Dispatcher::class)) { + $this->connection->setEventDispatcher($this->container->get(Dispatcher::class)); + } + + // Configure transaction manager for after-commit callbacks + if ($this->container->has(DatabaseTransactionsManager::class)) { + $this->connection->setTransactionManager($this->container->get(DatabaseTransactionsManager::class)); + } + + // Set up reconnector for the connection + $this->connection->setReconnector(function ($connection) { + $this->logger->warning('Database connection refreshing.'); + $this->refresh($connection); + }); + + // Dispatch connection established event + if ($this->container->has(Dispatcher::class)) { + $this->container->get(Dispatcher::class)->dispatch( + new ConnectionEstablished($this->connection) + ); + } + + $this->lastUseTime = microtime(true); + + return true; + } + + /** + * Check if the connection is still valid. + */ + public function check(): bool + { + if ($this->connection === null) { + return false; + } + + $maxIdleTime = $this->pool->getOption()->getMaxIdleTime(); + $now = microtime(true); + + if ($now > $maxIdleTime + $this->lastUseTime) { + return false; + } + + $this->lastUseTime = $now; + + return true; + } + + /** + * Close the database connection. + */ + public function close(): bool + { + if ($this->connection instanceof Connection) { + $this->connection->disconnect(); + } + + $this->connection = null; + + return true; + } + + /** + * Release the connection back to the pool. + */ + public function release(): void + { + try { + if ($this->connection instanceof Connection) { + // Reset all per-request state to prevent leaks between coroutines + $this->connection->resetForPool(); + + // Check error count and mark as stale if too high + if ($this->connection->getErrorCount() > self::MAX_ERROR_COUNT) { + $this->logger->warning('Connection has too many errors, marking as stale.'); + $this->lastUseTime = 0.0; + } + + // Roll back any uncommitted transactions (including nested savepoints) + if ($this->connection->transactionLevel() > 0) { + $this->connection->rollBack(0); + $this->logger->error('Database transaction was not committed or rolled back before release.'); + } + } + + $this->lastReleaseTime = microtime(true); + + // Dispatch release event if configured + $events = $this->pool->getOption()->getEvents(); + if (in_array(ReleaseConnection::class, $events, true)) { + $this->dispatcher?->dispatch(new ReleaseConnection($this)); + } + } catch (Throwable $exception) { + $this->logger->error('Release connection failed: ' . $exception); + // Mark as stale so it will be recreated + $this->lastUseTime = 0.0; + } finally { + $this->pool->release($this); + } + } + + /** + * Get the last use time. + */ + public function getLastUseTime(): float + { + return $this->lastUseTime; + } + + /** + * Get the last release time. + */ + public function getLastReleaseTime(): float + { + return $this->lastReleaseTime; + } + + /** + * Refresh the PDO connections. + */ + protected function refresh(Connection $connection): void + { + $fresh = $this->factory->make($this->config, $this->config['name'] ?? null); + + $connection->disconnect(); + $connection->setPdo($fresh->getPdo()); + $connection->setReadPdo($fresh->getReadPdo()); + + $this->logger->warning('Database connection refreshed.'); + } +} diff --git a/src/database/src/PostgresConnection.php b/src/database/src/PostgresConnection.php new file mode 100755 index 000000000..9b9c57e8d --- /dev/null +++ b/src/database/src/PostgresConnection.php @@ -0,0 +1,96 @@ +getCode() === '23505'; + } + + /** + * Get the default query grammar instance. + */ + protected function getDefaultQueryGrammar(): PostgresGrammar + { + return new PostgresGrammar($this); + } + + /** + * Get a schema builder instance for the connection. + */ + public function getSchemaBuilder(): PostgresBuilder + { + if (is_null($this->schemaGrammar)) { + $this->useDefaultSchemaGrammar(); + } + + return new PostgresBuilder($this); + } + + /** + * Get the default schema grammar instance. + */ + protected function getDefaultSchemaGrammar(): PostgresSchemaGrammar + { + return new PostgresSchemaGrammar($this); + } + + /** + * Get the schema state for the connection. + */ + #[Override] + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null): PostgresSchemaState + { + return new PostgresSchemaState($this, $files, $processFactory); + } + + /** + * Get the default post processor instance. + */ + protected function getDefaultPostProcessor(): PostgresProcessor + { + return new PostgresProcessor(); + } +} diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php new file mode 100644 index 000000000..e94a1d67b --- /dev/null +++ b/src/database/src/Query/Builder.php @@ -0,0 +1,3920 @@ + */ + use BuildsWhereDateClauses, BuildsQueries, ExplainsQueries, ForwardsCalls, Macroable { + __call as macroCall; + } + + /** + * The database connection instance. + */ + public ConnectionInterface $connection; + + /** + * The database query grammar instance. + */ + public Grammar $grammar; + + /** + * The database query post processor instance. + */ + public Processor $processor; + + /** + * The current query value bindings. + * + * @var array{ + * select: list, + * from: list, + * join: list, + * where: list, + * groupBy: list, + * having: list, + * order: list, + * union: list, + * unionOrder: list, + * } + */ + public array $bindings = [ + 'select' => [], + 'from' => [], + 'join' => [], + 'where' => [], + 'groupBy' => [], + 'having' => [], + 'order' => [], + 'union' => [], + 'unionOrder' => [], + ]; + + /** + * An aggregate function and column to be run. + * + * @var null|array{ + * function: string, + * columns: array<\Hypervel\Contracts\Database\Query\Expression|string> + * } + */ + public ?array $aggregate = null; + + /** + * The columns that should be returned. + * + * @var null|array<\Hypervel\Contracts\Database\Query\Expression|string> + */ + public ?array $columns = null; + + /** + * Indicates if the query returns distinct results. + * + * Occasionally contains the columns that should be distinct. + */ + public bool|array $distinct = false; + + /** + * The table which the query is targeting. + */ + public Expression|string $from; + + /** + * The index hint for the query. + */ + public ?IndexHint $indexHint = null; + + /** + * The table joins for the query. + */ + public ?array $joins = null; + + /** + * The where constraints for the query. + */ + public array $wheres = []; + + /** + * The groupings for the query. + */ + public ?array $groups = null; + + /** + * The having constraints for the query. + */ + public ?array $havings = null; + + /** + * The orderings for the query. + */ + public ?array $orders = null; + + /** + * The maximum number of records to return. + */ + public ?int $limit = null; + + /** + * The maximum number of records to return per group. + */ + public ?array $groupLimit = null; + + /** + * The number of records to skip. + */ + public ?int $offset = null; + + /** + * The query union statements. + */ + public ?array $unions = null; + + /** + * The maximum number of union records to return. + */ + public ?int $unionLimit = null; + + /** + * The number of union records to skip. + */ + public ?int $unionOffset = null; + + /** + * The orderings for the union query. + */ + public ?array $unionOrders = null; + + /** + * Indicates whether row locking is being used. + */ + public string|bool|null $lock = null; + + /** + * The callbacks that should be invoked before the query is executed. + */ + public array $beforeQueryCallbacks = []; + + /** + * The callbacks that should be invoked after retrieving data from the database. + */ + protected array $afterQueryCallbacks = []; + + /** + * All of the available clause operators. + * + * @var string[] + */ + public array $operators = [ + '=', '<', '>', '<=', '>=', '<>', '!=', '<=>', + 'like', 'like binary', 'not like', 'ilike', + '&', '|', '^', '<<', '>>', '&~', 'is', 'is not', + 'rlike', 'not rlike', 'regexp', 'not regexp', + '~', '~*', '!~', '!~*', 'similar to', + 'not similar to', 'not ilike', '~~*', '!~~*', + ]; + + /** + * All of the available bitwise operators. + * + * @var string[] + */ + public array $bitwiseOperators = [ + '&', '|', '^', '<<', '>>', '&~', + ]; + + /** + * Whether to use write pdo for the select. + */ + public bool $useWritePdo = false; + + /** + * Create a new query builder instance. + */ + public function __construct( + ConnectionInterface $connection, + ?Grammar $grammar = null, + ?Processor $processor = null, + ) { + $this->connection = $connection; + $this->grammar = $grammar ?: $connection->getQueryGrammar(); + $this->processor = $processor ?: $connection->getPostProcessor(); + } + + /** + * Set the columns to be selected. + */ + public function select(mixed $columns = ['*']): static + { + $this->columns = []; + $this->bindings['select'] = []; + + $columns = is_array($columns) ? $columns : func_get_args(); + + foreach ($columns as $as => $column) { + if (is_string($as) && $this->isQueryable($column)) { + $this->selectSub($column, $as); + } else { + $this->columns[] = $column; + } + } + + return $this; + } + + /** + * Add a subselect expression to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|string $query + * + * @throws InvalidArgumentException + */ + public function selectSub(Closure|self|EloquentBuilder|string $query, string $as): static + { + [$query, $bindings] = $this->createSub($query); + + return $this->selectRaw( + '(' . $query . ') as ' . $this->grammar->wrap($as), + $bindings + ); + } + + /** + * Add a new "raw" select expression to the query. + */ + public function selectRaw(string $expression, array $bindings = []): static + { + $this->addSelect(new Expression($expression)); + + if ($bindings) { + $this->addBinding($bindings, 'select'); + } + + return $this; + } + + /** + * Makes "from" fetch from a subquery. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|string $query + * + * @throws InvalidArgumentException + */ + public function fromSub(Closure|self|EloquentBuilder|string $query, string $as): static + { + [$query, $bindings] = $this->createSub($query); + + return $this->fromRaw('(' . $query . ') as ' . $this->grammar->wrapTable($as), $bindings); + } + + /** + * Add a raw "from" clause to the query. + */ + public function fromRaw(string $expression, mixed $bindings = []): static + { + $this->from = new Expression($expression); + + $this->addBinding($bindings, 'from'); + + return $this; + } + + /** + * Creates a subquery and parse it. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|string $query + */ + protected function createSub(Closure|self|EloquentBuilder|string $query): array + { + // If the given query is a Closure, we will execute it while passing in a new + // query instance to the Closure. This will give the developer a chance to + // format and work with the query before we cast it to a raw SQL string. + if ($query instanceof Closure) { + $callback = $query; + + $callback($query = $this->forSubQuery()); + } + + return $this->parseSub($query); + } + + /** + * Parse the subquery into SQL and bindings. + * + * @throws InvalidArgumentException + */ + protected function parseSub(mixed $query): array + { + if ($query instanceof self || $query instanceof EloquentBuilder || $query instanceof Relation) { + $query = $this->prependDatabaseNameIfCrossDatabaseQuery($query); + + return [$query->toSql(), $query->getBindings()]; + } + if (is_string($query)) { + return [$query, []]; + } + throw new InvalidArgumentException( + 'A subquery must be a query builder instance, a Closure, or a string.' + ); + } + + /** + * Prepend the database name if the given query is on another database. + */ + protected function prependDatabaseNameIfCrossDatabaseQuery(self|EloquentBuilder|Relation $query): self|EloquentBuilder|Relation + { + if ($query->getConnection()->getDatabaseName() + !== $this->getConnection()->getDatabaseName()) { + $databaseName = $query->getConnection()->getDatabaseName(); + + if (! str_starts_with($query->from, $databaseName) && ! str_contains($query->from, '.')) { + $query->from($databaseName . '.' . $query->from); + } + } + + return $query; + } + + /** + * Add a new select column to the query. + */ + public function addSelect(mixed $column): static + { + $columns = is_array($column) ? $column : func_get_args(); + + foreach ($columns as $as => $column) { + if (is_string($as) && $this->isQueryable($column)) { + if (is_null($this->columns)) { + $this->select($this->from . '.*'); + } + + $this->selectSub($column, $as); + } else { + if (is_array($this->columns) && in_array($column, $this->columns, true)) { + continue; + } + + $this->columns[] = $column; + } + } + + return $this; + } + + /** + * Add a vector-similarity selection to the query. + * + * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + */ + public function selectVectorDistance(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, ?string $as = null): static + { + $this->ensureConnectionSupportsVectors(); + + if (is_string($vector)) { + $vector = Str::of($vector)->toEmbeddings(cache: true); + } + + $this->addBinding( + json_encode( + $vector instanceof Arrayable + ? $vector->toArray() + : $vector, + flags: JSON_THROW_ON_ERROR + ), + 'select', + ); + + $as = $this->getGrammar()->wrap($as ?? $column . '_distance'); + + return $this->addSelect( + new Expression("({$this->getGrammar()->wrap($column)} <=> ?) as {$as}") + ); + } + + /** + * Force the query to only return distinct results. + */ + public function distinct(): static + { + $columns = func_get_args(); + + if (count($columns) > 0) { + $this->distinct = is_array($columns[0]) || is_bool($columns[0]) ? $columns[0] : $columns; + } else { + $this->distinct = true; + } + + return $this; + } + + /** + * Set the table which the query is targeting. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Contracts\Database\Query\Expression|string $table + */ + public function from(Closure|self|EloquentBuilder|ExpressionContract|string $table, ?string $as = null): static + { + if ($this->isQueryable($table)) { + return $this->fromSub($table, $as); + } + + $this->from = $as ? "{$table} as {$as}" : $table; + + return $this; + } + + /** + * Add an index hint to suggest a query index. + */ + public function useIndex(string $index): static + { + $this->indexHint = new IndexHint('hint', $index); + + return $this; + } + + /** + * Add an index hint to force a query index. + */ + public function forceIndex(string $index): static + { + $this->indexHint = new IndexHint('force', $index); + + return $this; + } + + /** + * Add an index hint to ignore a query index. + */ + public function ignoreIndex(string $index): static + { + $this->indexHint = new IndexHint('ignore', $index); + + return $this; + } + + /** + * Add a "join" clause to the query. + */ + public function join(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null, string $type = 'inner', bool $where = false): static + { + $join = $this->newJoinClause($this, $type, $table); + + // If the first "column" of the join is really a Closure instance the developer + // is trying to build a join with a complex "on" clause containing more than + // one condition, so we'll add the join and call a Closure with the query. + if ($first instanceof Closure) { + $first($join); + + $this->joins[] = $join; + + $this->addBinding($join->getBindings(), 'join'); + } + + // If the column is simply a string, we can assume the join simply has a basic + // "on" clause with a single condition. So we will just build the join with + // this simple join clauses attached to it. There is not a join callback. + else { + $method = $where ? 'where' : 'on'; + + $this->joins[] = $join->{$method}($first, $operator, $second); + + $this->addBinding($join->getBindings(), 'join'); + } + + return $this; + } + + /** + * Add a "join where" clause to the query. + */ + public function joinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, ExpressionContract|string $second, string $type = 'inner'): static + { + return $this->join($table, $first, $operator, $second, $type, true); + } + + /** + * Add a "subquery join" clause to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|string $query + * + * @throws InvalidArgumentException + */ + public function joinSub(Closure|self|EloquentBuilder|string $query, string $as, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null, string $type = 'inner', bool $where = false): static + { + [$query, $bindings] = $this->createSub($query); + + $expression = '(' . $query . ') as ' . $this->grammar->wrapTable($as); + + $this->addBinding($bindings, 'join'); + + return $this->join(new Expression($expression), $first, $operator, $second, $type, $where); + } + + /** + * Add a "lateral join" clause to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|string $query + */ + public function joinLateral(Closure|self|EloquentBuilder|string $query, string $as, string $type = 'inner'): static + { + [$query, $bindings] = $this->createSub($query); + + $expression = '(' . $query . ') as ' . $this->grammar->wrapTable($as); + + $this->addBinding($bindings, 'join'); + + $this->joins[] = $this->newJoinLateralClause($this, $type, new Expression($expression)); + + return $this; + } + + /** + * Add a lateral left join to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|string $query + */ + public function leftJoinLateral(Closure|self|EloquentBuilder|string $query, string $as): static + { + return $this->joinLateral($query, $as, 'left'); + } + + /** + * Add a left join to the query. + */ + public function leftJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + { + return $this->join($table, $first, $operator, $second, 'left'); + } + + /** + * Add a "join where" clause to the query. + */ + public function leftJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, ExpressionContract|string|null $second): static + { + return $this->joinWhere($table, $first, $operator, $second, 'left'); + } + + /** + * Add a subquery left join to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|string $query + */ + public function leftJoinSub(Closure|self|EloquentBuilder|string $query, string $as, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + { + return $this->joinSub($query, $as, $first, $operator, $second, 'left'); + } + + /** + * Add a right join to the query. + */ + public function rightJoin(ExpressionContract|string $table, Closure|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + { + return $this->join($table, $first, $operator, $second, 'right'); + } + + /** + * Add a "right join where" clause to the query. + */ + public function rightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, ExpressionContract|string $second): static + { + return $this->joinWhere($table, $first, $operator, $second, 'right'); + } + + /** + * Add a subquery right join to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|string $query + */ + public function rightJoinSub(Closure|self|EloquentBuilder|string $query, string $as, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + { + return $this->joinSub($query, $as, $first, $operator, $second, 'right'); + } + + /** + * Add a "cross join" clause to the query. + */ + public function crossJoin(ExpressionContract|string $table, Closure|ExpressionContract|string|null $first = null, ?string $operator = null, ExpressionContract|string|null $second = null): static + { + if ($first) { + return $this->join($table, $first, $operator, $second, 'cross'); + } + + $this->joins[] = $this->newJoinClause($this, 'cross', $table); + + return $this; + } + + /** + * Add a subquery cross join to the query. + */ + public function crossJoinSub(Closure|self|EloquentBuilder|string $query, string $as): static + { + [$query, $bindings] = $this->createSub($query); + + $expression = '(' . $query . ') as ' . $this->grammar->wrapTable($as); + + $this->addBinding($bindings, 'join'); + + $this->joins[] = $this->newJoinClause($this, 'cross', new Expression($expression)); + + return $this; + } + + /** + * Get a new "join" clause. + */ + protected function newJoinClause(self $parentQuery, string $type, ExpressionContract|string $table): JoinClause + { + return new JoinClause($parentQuery, $type, $table); + } + + /** + * Get a new "join lateral" clause. + */ + protected function newJoinLateralClause(self $parentQuery, string $type, ExpressionContract|string $table): JoinLateralClause + { + return new JoinLateralClause($parentQuery, $type, $table); + } + + /** + * Merge an array of "where" clauses and bindings. + */ + public function mergeWheres(array $wheres, array $bindings): static + { + $this->wheres = array_merge($this->wheres, (array) $wheres); + + $this->bindings['where'] = array_values( + array_merge($this->bindings['where'], (array) $bindings) + ); + + return $this; + } + + /** + * Add a basic "where" clause to the query. + */ + public function where(Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static + { + if ($column instanceof ConditionExpression) { + $type = 'Expression'; + + $this->wheres[] = compact('type', 'column', 'boolean'); + + return $this; + } + + // If the column is an array, we will assume it is an array of key-value pairs + // and can add them each as a where clause. We will maintain the boolean we + // received when the method was called and pass it into the nested where. + if (is_array($column)) { + return $this->addArrayOfWheres($column, $boolean); + } + + // Here we will make some assumptions about the operator. If only 2 values are + // passed to the method, we will assume that the operator is an equals sign + // and keep going. Otherwise, we'll require the operator to be passed in. + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + // If the column is actually a Closure instance, we will assume the developer + // wants to begin a nested where statement which is wrapped in parentheses. + // We will add that Closure to the query and return back out immediately. + if ($column instanceof Closure && is_null($operator)) { + return $this->whereNested($column, $boolean); + } + + // If the column is a Closure instance and there is an operator value, we will + // assume the developer wants to run a subquery and then compare the result + // of that subquery with the given value that was provided to the method. + if ($this->isQueryable($column) && ! is_null($operator)) { + [$sub, $bindings] = $this->createSub($column); + + return $this->addBinding($bindings, 'where') + ->where(new Expression('(' . $sub . ')'), $operator, $value, $boolean); + } + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + // If the value is a Closure, it means the developer is performing an entire + // sub-select within the query and we will need to compile the sub-select + // within the where clause to get the appropriate query record results. + if ($this->isQueryable($value)) { + return $this->whereSub($column, $operator, $value, $boolean); + } + + // If the value is "null", we will just assume the developer wants to add a + // where null clause to the query. So, we will allow a short-cut here to + // that method for convenience so the developer doesn't have to check. + if (is_null($value)) { + return $this->whereNull($column, $boolean, ! in_array($operator, ['=', '<=>'], true)); + } + + $type = 'Basic'; + + $columnString = ($column instanceof ExpressionContract) + ? $this->grammar->getValue($column) + : $column; + + // If the column is making a JSON reference we'll check to see if the value + // is a boolean. If it is, we'll add the raw boolean string as an actual + // value to the query to ensure this is properly handled by the query. + if (str_contains($columnString, '->') && is_bool($value)) { + $value = new Expression($value ? 'true' : 'false'); + + if (is_string($column)) { + $type = 'JsonBoolean'; + } + } + + if ($this->isBitwiseOperator($operator)) { + $type = 'Bitwise'; + } + + // Now that we are working with just a simple query we can put the elements + // in our array and add the query binding to our array of bindings that + // will be bound to each SQL statements when it is finally executed. + $this->wheres[] = compact( + 'type', + 'column', + 'operator', + 'value', + 'boolean' + ); + + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->flattenValue($value), 'where'); + } + + return $this; + } + + /** + * Add an array of "where" clauses to the query. + */ + protected function addArrayOfWheres(array $column, string $boolean, string $method = 'where'): static + { + return $this->whereNested(function ($query) use ($column, $method, $boolean) { + foreach ($column as $key => $value) { + if (is_numeric($key) && is_array($value)) { + $query->{$method}(...array_values($value), boolean: $boolean); + } else { + $query->{$method}($key, '=', $value, $boolean); + } + } + }, $boolean); + } + + /** + * Prepare the value and operator for a where clause. + * + * @throws InvalidArgumentException + */ + public function prepareValueAndOperator(mixed $value, mixed $operator, bool $useDefault = false): array + { + if ($useDefault) { + return [$operator, '=']; + } + if ($this->invalidOperatorAndValue($operator, $value)) { + throw new InvalidArgumentException('Illegal operator and value combination.'); + } + + return [$value, $operator]; + } + + /** + * Determine if the given operator and value combination is legal. + * + * Prevents using Null values with invalid operators. + */ + protected function invalidOperatorAndValue(mixed $operator, mixed $value): bool + { + return is_null($value) && in_array($operator, $this->operators) + && ! in_array($operator, ['=', '<=>', '<>', '!=']); + } + + /** + * Determine if the given operator is supported. + */ + protected function invalidOperator(mixed $operator): bool + { + return ! is_string($operator) || (! in_array(strtolower($operator), $this->operators, true) + && ! in_array(strtolower($operator), $this->grammar->getOperators(), true)); + } + + /** + * Determine if the operator is a bitwise operator. + */ + protected function isBitwiseOperator(string $operator): bool + { + return in_array(strtolower($operator), $this->bitwiseOperators, true) + || in_array(strtolower($operator), $this->grammar->getBitwiseOperators(), true); + } + + /** + * Add an "or where" clause to the query. + */ + public function orWhere(Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + return $this->where($column, $operator, $value, 'or'); + } + + /** + * Add a basic "where not" clause to the query. + */ + public function whereNot(Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static + { + if (is_array($column)) { + return $this->whereNested(function ($query) use ($column, $operator, $value, $boolean) { + $query->where($column, $operator, $value, $boolean); + }, $boolean . ' not'); + } + + return $this->where($column, $operator, $value, $boolean . ' not'); + } + + /** + * Add an "or where not" clause to the query. + */ + public function orWhereNot(Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static + { + return $this->whereNot($column, $operator, $value, 'or'); + } + + /** + * Add a "where" clause comparing two columns to the query. + */ + public function whereColumn(ExpressionContract|string|array $first, ?string $operator = null, ?string $second = null, string $boolean = 'and'): static + { + // If the column is an array, we will assume it is an array of key-value pairs + // and can add them each as a where clause. We will maintain the boolean we + // received when the method was called and pass it into the nested where. + if (is_array($first)) { + return $this->addArrayOfWheres($first, $boolean, 'whereColumn'); + } + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$second, $operator] = [$operator, '=']; + } + + // Finally, we will add this where clause into this array of clauses that we + // are building for the query. All of them will be compiled via a grammar + // once the query is about to be executed and run against the database. + $type = 'Column'; + + $this->wheres[] = compact( + 'type', + 'first', + 'operator', + 'second', + 'boolean' + ); + + return $this; + } + + /** + * Add an "or where" clause comparing two columns to the query. + */ + public function orWhereColumn(ExpressionContract|string|array $first, ?string $operator = null, ?string $second = null): static + { + return $this->whereColumn($first, $operator, $second, 'or'); + } + + /** + * Add a vector similarity clause to the query, filtering by minimum similarity and ordering by similarity. + * + * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + * @param float $minSimilarity A value between 0.0 and 1.0, where 1.0 is identical. + */ + public function whereVectorSimilarTo(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, float $minSimilarity = 0.6, bool $order = true): static + { + if (is_string($vector)) { + $vector = Str::of($vector)->toEmbeddings(cache: true); + } + + $this->whereVectorDistanceLessThan($column, $vector, 1 - $minSimilarity); + + if ($order) { + $this->orderByVectorDistance($column, $vector); + } + + return $this; + } + + /** + * Add a vector distance "where" clause to the query. + * + * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + */ + public function whereVectorDistanceLessThan(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, float $maxDistance, string $boolean = 'and'): static + { + $this->ensureConnectionSupportsVectors(); + + if (is_string($vector)) { + $vector = Str::of($vector)->toEmbeddings(cache: true); + } + + return $this->whereRaw( + "({$this->getGrammar()->wrap($column)} <=> ?) <= ?", + [ + json_encode( + $vector instanceof Arrayable + ? $vector->toArray() + : $vector, + flags: JSON_THROW_ON_ERROR + ), + $maxDistance, + ], + $boolean + ); + } + + /** + * Add a vector distance "or where" clause to the query. + * + * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + */ + public function orWhereVectorDistanceLessThan(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, float $maxDistance): static + { + return $this->whereVectorDistanceLessThan($column, $vector, $maxDistance, 'or'); + } + + /** + * Add a raw "where" clause to the query. + */ + public function whereRaw(ExpressionContract|string $sql, mixed $bindings = [], string $boolean = 'and'): static + { + $this->wheres[] = ['type' => 'raw', 'sql' => $sql, 'boolean' => $boolean]; + + $this->addBinding((array) $bindings, 'where'); + + return $this; + } + + /** + * Add a raw "or where" clause to the query. + */ + public function orWhereRaw(string $sql, mixed $bindings = []): static + { + return $this->whereRaw($sql, $bindings, 'or'); + } + + /** + * Add a "where like" clause to the query. + */ + public function whereLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false, string $boolean = 'and', bool $not = false): static + { + $type = 'Like'; + + $this->wheres[] = compact('type', 'column', 'value', 'caseSensitive', 'boolean', 'not'); + + if (method_exists($this->grammar, 'prepareWhereLikeBinding')) { + $value = $this->grammar->prepareWhereLikeBinding($value, $caseSensitive); + } + + $this->addBinding($value); + + return $this; + } + + /** + * Add an "or where like" clause to the query. + */ + public function orWhereLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false): static + { + return $this->whereLike($column, $value, $caseSensitive, 'or', false); + } + + /** + * Add a "where not like" clause to the query. + */ + public function whereNotLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false, string $boolean = 'and'): static + { + return $this->whereLike($column, $value, $caseSensitive, $boolean, true); + } + + /** + * Add an "or where not like" clause to the query. + */ + public function orWhereNotLike(ExpressionContract|string $column, string $value, bool $caseSensitive = false): static + { + return $this->whereNotLike($column, $value, $caseSensitive, 'or'); + } + + /** + * Add a "where in" clause to the query. + */ + public function whereIn(ExpressionContract|string $column, mixed $values, string $boolean = 'and', bool $not = false): static + { + $type = $not ? 'NotIn' : 'In'; + + // If the value is a query builder instance we will assume the developer wants to + // look for any values that exist within this given query. So, we will add the + // query accordingly so that this query is properly executed when it is run. + if ($this->isQueryable($values)) { + [$query, $bindings] = $this->createSub($values); + + $values = [new Expression($query)]; + + $this->addBinding($bindings, 'where'); + } + + // Next, if the value is Arrayable we need to cast it to its raw array form so we + // have the underlying array value instead of an Arrayable object which is not + // able to be added as a binding, etc. We will then add to the wheres array. + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + $this->wheres[] = compact('type', 'column', 'values', 'boolean'); + + if (count($values) !== count(Arr::flatten($values, 1))) { + throw new InvalidArgumentException('Nested arrays may not be passed to whereIn method.'); + } + + // Finally, we'll add a binding for each value unless that value is an expression + // in which case we will just skip over it since it will be the query as a raw + // string and not as a parameterized place-holder to be replaced by the PDO. + $this->addBinding($this->cleanBindings($values), 'where'); + + return $this; + } + + /** + * Add an "or where in" clause to the query. + */ + public function orWhereIn(ExpressionContract|string $column, mixed $values): static + { + return $this->whereIn($column, $values, 'or'); + } + + /** + * Add a "where not in" clause to the query. + */ + public function whereNotIn(ExpressionContract|string $column, mixed $values, string $boolean = 'and'): static + { + return $this->whereIn($column, $values, $boolean, true); + } + + /** + * Add an "or where not in" clause to the query. + */ + public function orWhereNotIn(ExpressionContract|string $column, mixed $values): static + { + return $this->whereNotIn($column, $values, 'or'); + } + + /** + * Add a "where in raw" clause for integer values to the query. + */ + public function whereIntegerInRaw(string $column, Arrayable|array $values, string $boolean = 'and', bool $not = false): static + { + $type = $not ? 'NotInRaw' : 'InRaw'; + + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + $values = Arr::flatten($values); + + foreach ($values as &$value) { + $value = (int) ($value instanceof BackedEnum ? $value->value : $value); + } + + $this->wheres[] = compact('type', 'column', 'values', 'boolean'); + + return $this; + } + + /** + * Add an "or where in raw" clause for integer values to the query. + */ + public function orWhereIntegerInRaw(string $column, Arrayable|array $values): static + { + return $this->whereIntegerInRaw($column, $values, 'or'); + } + + /** + * Add a "where not in raw" clause for integer values to the query. + */ + public function whereIntegerNotInRaw(string $column, Arrayable|array $values, string $boolean = 'and'): static + { + return $this->whereIntegerInRaw($column, $values, $boolean, true); + } + + /** + * Add an "or where not in raw" clause for integer values to the query. + */ + public function orWhereIntegerNotInRaw(string $column, Arrayable|array $values): static + { + return $this->whereIntegerNotInRaw($column, $values, 'or'); + } + + /** + * Add a "where null" clause to the query. + */ + public function whereNull(string|array|ExpressionContract $columns, string $boolean = 'and', bool $not = false): static + { + $type = $not ? 'NotNull' : 'Null'; + + foreach (Arr::wrap($columns) as $column) { + $this->wheres[] = compact('type', 'column', 'boolean'); + } + + return $this; + } + + /** + * Add an "or where null" clause to the query. + */ + public function orWhereNull(string|array|ExpressionContract $column): static + { + return $this->whereNull($column, 'or'); + } + + /** + * Add a "where not null" clause to the query. + */ + public function whereNotNull(string|array|ExpressionContract $columns, string $boolean = 'and'): static + { + return $this->whereNull($columns, $boolean, true); + } + + /** + * Add a "where between" statement to the query. + * + * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Contracts\Database\Query\Expression|string $column + */ + public function whereBetween(self|EloquentBuilder|ExpressionContract|string $column, iterable $values, string $boolean = 'and', bool $not = false): static + { + $type = 'between'; + + if ($this->isQueryable($column)) { + [$sub, $bindings] = $this->createSub($column); + + return $this->addBinding($bindings, 'where') + ->whereBetween(new Expression('(' . $sub . ')'), $values, $boolean, $not); + } + + if ($values instanceof CarbonPeriod) { + $values = [$values->getStartDate(), $values->getEndDate()]; + } + + $this->wheres[] = compact('type', 'column', 'values', 'boolean', 'not'); + + $this->addBinding(array_slice($this->cleanBindings(Arr::flatten($values)), 0, 2), 'where'); + + return $this; + } + + /** + * Add a "where between" statement using columns to the query. + */ + public function whereBetweenColumns(ExpressionContract|string $column, array $values, string $boolean = 'and', bool $not = false): static + { + $type = 'betweenColumns'; + + $this->wheres[] = compact('type', 'column', 'values', 'boolean', 'not'); + + return $this; + } + + /** + * Add an "or where between" statement to the query. + * + * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Contracts\Database\Query\Expression|string $column + */ + public function orWhereBetween(self|EloquentBuilder|ExpressionContract|string $column, iterable $values): static + { + return $this->whereBetween($column, $values, 'or'); + } + + /** + * Add an "or where between" statement using columns to the query. + */ + public function orWhereBetweenColumns(ExpressionContract|string $column, array $values): static + { + return $this->whereBetweenColumns($column, $values, 'or'); + } + + /** + * Add a "where not between" statement to the query. + * + * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Contracts\Database\Query\Expression|string $column + */ + public function whereNotBetween(self|EloquentBuilder|ExpressionContract|string $column, iterable $values, string $boolean = 'and'): static + { + return $this->whereBetween($column, $values, $boolean, true); + } + + /** + * Add a "where not between" statement using columns to the query. + */ + public function whereNotBetweenColumns(ExpressionContract|string $column, array $values, string $boolean = 'and'): static + { + return $this->whereBetweenColumns($column, $values, $boolean, true); + } + + /** + * Add an "or where not between" statement to the query. + * + * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Contracts\Database\Query\Expression|string $column + */ + public function orWhereNotBetween(self|EloquentBuilder|ExpressionContract|string $column, iterable $values): static + { + return $this->whereNotBetween($column, $values, 'or'); + } + + /** + * Add an "or where not between" statement using columns to the query. + */ + public function orWhereNotBetweenColumns(ExpressionContract|string $column, array $values): static + { + return $this->whereNotBetweenColumns($column, $values, 'or'); + } + + /** + * Add a "where between columns" statement using a value to the query. + * + * @param array{\Hypervel\Contracts\Database\Query\Expression|string, \Hypervel\Contracts\Database\Query\Expression|string} $columns + */ + public function whereValueBetween(mixed $value, array $columns, string $boolean = 'and', bool $not = false): static + { + $type = 'valueBetween'; + + $this->wheres[] = compact('type', 'value', 'columns', 'boolean', 'not'); + + $this->addBinding($value, 'where'); + + return $this; + } + + /** + * Add an "or where between columns" statement using a value to the query. + * + * @param array{\Hypervel\Contracts\Database\Query\Expression|string, \Hypervel\Contracts\Database\Query\Expression|string} $columns + */ + public function orWhereValueBetween(mixed $value, array $columns): static + { + return $this->whereValueBetween($value, $columns, 'or'); + } + + /** + * Add a "where not between columns" statement using a value to the query. + * + * @param array{\Hypervel\Contracts\Database\Query\Expression|string, \Hypervel\Contracts\Database\Query\Expression|string} $columns + */ + public function whereValueNotBetween(mixed $value, array $columns, string $boolean = 'and'): static + { + return $this->whereValueBetween($value, $columns, $boolean, true); + } + + /** + * Add an "or where not between columns" statement using a value to the query. + * + * @param array{\Hypervel\Contracts\Database\Query\Expression|string, \Hypervel\Contracts\Database\Query\Expression|string} $columns + */ + public function orWhereValueNotBetween(mixed $value, array $columns): static + { + return $this->whereValueNotBetween($value, $columns, 'or'); + } + + /** + * Add an "or where not null" clause to the query. + */ + public function orWhereNotNull(ExpressionContract|string $column): static + { + return $this->whereNotNull($column, 'or'); + } + + /** + * Add a "where date" statement to the query. + */ + public function whereDate(ExpressionContract|string $column, DateTimeInterface|string|null $operator, DateTimeInterface|string|null $value = null, string $boolean = 'and'): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + + if ($value instanceof DateTimeInterface) { + $value = $value->format('Y-m-d'); + } + + return $this->addDateBasedWhere('Date', $column, $operator, $value, $boolean); + } + + /** + * Add an "or where date" statement to the query. + */ + public function orWhereDate(ExpressionContract|string $column, DateTimeInterface|string|null $operator, DateTimeInterface|string|null $value = null): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + return $this->whereDate($column, $operator, $value, 'or'); + } + + /** + * Add a "where time" statement to the query. + */ + public function whereTime(ExpressionContract|string $column, DateTimeInterface|string|null $operator, DateTimeInterface|string|null $value = null, string $boolean = 'and'): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + + if ($value instanceof DateTimeInterface) { + $value = $value->format('H:i:s'); + } + + return $this->addDateBasedWhere('Time', $column, $operator, $value, $boolean); + } + + /** + * Add an "or where time" statement to the query. + */ + public function orWhereTime(ExpressionContract|string $column, DateTimeInterface|string|null $operator, DateTimeInterface|string|null $value = null): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + return $this->whereTime($column, $operator, $value, 'or'); + } + + /** + * Add a "where day" statement to the query. + */ + public function whereDay(ExpressionContract|string $column, DateTimeInterface|string|int|null $operator, DateTimeInterface|string|int|null $value = null, string $boolean = 'and'): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + + if ($value instanceof DateTimeInterface) { + $value = $value->format('d'); + } + + if (! $value instanceof ExpressionContract) { + $value = sprintf('%02d', $value); + } + + return $this->addDateBasedWhere('Day', $column, $operator, $value, $boolean); + } + + /** + * Add an "or where day" statement to the query. + */ + public function orWhereDay(ExpressionContract|string $column, DateTimeInterface|string|int|null $operator, DateTimeInterface|string|int|null $value = null): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + return $this->whereDay($column, $operator, $value, 'or'); + } + + /** + * Add a "where month" statement to the query. + */ + public function whereMonth(ExpressionContract|string $column, DateTimeInterface|string|int|null $operator, DateTimeInterface|string|int|null $value = null, string $boolean = 'and'): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + + if ($value instanceof DateTimeInterface) { + $value = $value->format('m'); + } + + if (! $value instanceof ExpressionContract) { + $value = sprintf('%02d', $value); + } + + return $this->addDateBasedWhere('Month', $column, $operator, $value, $boolean); + } + + /** + * Add an "or where month" statement to the query. + */ + public function orWhereMonth(ExpressionContract|string $column, DateTimeInterface|string|int|null $operator, DateTimeInterface|string|int|null $value = null): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + return $this->whereMonth($column, $operator, $value, 'or'); + } + + /** + * Add a "where year" statement to the query. + */ + public function whereYear(ExpressionContract|string $column, DateTimeInterface|string|int|null $operator, DateTimeInterface|string|int|null $value = null, string $boolean = 'and'): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + + if ($value instanceof DateTimeInterface) { + $value = $value->format('Y'); + } + + return $this->addDateBasedWhere('Year', $column, $operator, $value, $boolean); + } + + /** + * Add an "or where year" statement to the query. + */ + public function orWhereYear(ExpressionContract|string $column, DateTimeInterface|string|int|null $operator, DateTimeInterface|string|int|null $value = null): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + return $this->whereYear($column, $operator, $value, 'or'); + } + + /** + * Add a date based (year, month, day, time) statement to the query. + */ + protected function addDateBasedWhere(string $type, ExpressionContract|string $column, string $operator, mixed $value, string $boolean = 'and'): static + { + $this->wheres[] = compact('column', 'type', 'boolean', 'operator', 'value'); + + if (! $value instanceof ExpressionContract) { + $this->addBinding($value, 'where'); + } + + return $this; + } + + /** + * Add a nested "where" statement to the query. + */ + public function whereNested(Closure $callback, string $boolean = 'and'): static + { + $callback($query = $this->forNestedWhere()); + + return $this->addNestedWhereQuery($query, $boolean); + } + + /** + * Create a new query instance for nested where condition. + */ + public function forNestedWhere(): self + { + return $this->newQuery()->from($this->from); + } + + /** + * Add another query builder as a nested where to the query builder. + */ + public function addNestedWhereQuery(self $query, string $boolean = 'and'): static + { + if (count($query->wheres)) { + $type = 'Nested'; + + $this->wheres[] = compact('type', 'query', 'boolean'); + + $this->addBinding($query->getRawBindings()['where'], 'where'); + } + + return $this; + } + + /** + * Add a full sub-select to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*> $callback + */ + protected function whereSub(ExpressionContract|string $column, string $operator, Closure|self|EloquentBuilder $callback, string $boolean): static + { + $type = 'Sub'; + + if ($callback instanceof Closure) { + // Once we have the query instance we can simply execute it so it can add all + // of the sub-select's conditions to itself, and then we can cache it off + // in the array of where clauses for the "main" parent query instance. + $callback($query = $this->forSubQuery()); + } else { + $query = $callback instanceof EloquentBuilder ? $callback->toBase() : $callback; + } + + $this->wheres[] = compact( + 'type', + 'column', + 'operator', + 'query', + 'boolean' + ); + + $this->addBinding($query->getBindings(), 'where'); + + return $this; + } + + /** + * Add an "exists" clause to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*> $callback + */ + public function whereExists(Closure|self|EloquentBuilder $callback, string $boolean = 'and', bool $not = false): static + { + if ($callback instanceof Closure) { + $query = $this->forSubQuery(); + + // Similar to the sub-select clause, we will create a new query instance so + // the developer may cleanly specify the entire exists query and we will + // compile the whole thing in the grammar and insert it into the SQL. + $callback($query); + } else { + $query = $callback instanceof EloquentBuilder ? $callback->toBase() : $callback; + } + + return $this->addWhereExistsQuery($query, $boolean, $not); + } + + /** + * Add an "or where exists" clause to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*> $callback + */ + public function orWhereExists(Closure|self|EloquentBuilder $callback, bool $not = false): static + { + return $this->whereExists($callback, 'or', $not); + } + + /** + * Add a "where not exists" clause to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*> $callback + */ + public function whereNotExists(Closure|self|EloquentBuilder $callback, string $boolean = 'and'): static + { + return $this->whereExists($callback, $boolean, true); + } + + /** + * Add an "or where not exists" clause to the query. + * + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*> $callback + */ + public function orWhereNotExists(Closure|self|EloquentBuilder $callback): static + { + return $this->orWhereExists($callback, true); + } + + /** + * Add an "exists" clause to the query. + */ + public function addWhereExistsQuery(self $query, string $boolean = 'and', bool $not = false): static + { + $type = $not ? 'NotExists' : 'Exists'; + + $this->wheres[] = compact('type', 'query', 'boolean'); + + $this->addBinding($query->getBindings(), 'where'); + + return $this; + } + + /** + * Adds a where condition using row values. + * + * @throws InvalidArgumentException + */ + public function whereRowValues(array $columns, string $operator, array $values, string $boolean = 'and'): static + { + if (count($columns) !== count($values)) { + throw new InvalidArgumentException('The number of columns must match the number of values'); + } + + $type = 'RowValues'; + + $this->wheres[] = compact('type', 'columns', 'operator', 'values', 'boolean'); + + $this->addBinding($this->cleanBindings($values)); + + return $this; + } + + /** + * Adds an or where condition using row values. + */ + public function orWhereRowValues(array $columns, string $operator, array $values): static + { + return $this->whereRowValues($columns, $operator, $values, 'or'); + } + + /** + * Add a "where JSON contains" clause to the query. + */ + public function whereJsonContains(string $column, mixed $value, string $boolean = 'and', bool $not = false): static + { + $type = 'JsonContains'; + + $this->wheres[] = compact('type', 'column', 'value', 'boolean', 'not'); + + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->grammar->prepareBindingForJsonContains($value)); + } + + return $this; + } + + /** + * Add an "or where JSON contains" clause to the query. + */ + public function orWhereJsonContains(string $column, mixed $value): static + { + return $this->whereJsonContains($column, $value, 'or'); + } + + /** + * Add a "where JSON not contains" clause to the query. + */ + public function whereJsonDoesntContain(string $column, mixed $value, string $boolean = 'and'): static + { + return $this->whereJsonContains($column, $value, $boolean, true); + } + + /** + * Add an "or where JSON not contains" clause to the query. + */ + public function orWhereJsonDoesntContain(string $column, mixed $value): static + { + return $this->whereJsonDoesntContain($column, $value, 'or'); + } + + /** + * Add a "where JSON overlaps" clause to the query. + */ + public function whereJsonOverlaps(string $column, mixed $value, string $boolean = 'and', bool $not = false): static + { + $type = 'JsonOverlaps'; + + $this->wheres[] = compact('type', 'column', 'value', 'boolean', 'not'); + + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->grammar->prepareBindingForJsonContains($value)); + } + + return $this; + } + + /** + * Add an "or where JSON overlaps" clause to the query. + */ + public function orWhereJsonOverlaps(string $column, mixed $value): static + { + return $this->whereJsonOverlaps($column, $value, 'or'); + } + + /** + * Add a "where JSON not overlap" clause to the query. + */ + public function whereJsonDoesntOverlap(string $column, mixed $value, string $boolean = 'and'): static + { + return $this->whereJsonOverlaps($column, $value, $boolean, true); + } + + /** + * Add an "or where JSON not overlap" clause to the query. + */ + public function orWhereJsonDoesntOverlap(string $column, mixed $value): static + { + return $this->whereJsonDoesntOverlap($column, $value, 'or'); + } + + /** + * Add a clause that determines if a JSON path exists to the query. + */ + public function whereJsonContainsKey(string $column, string $boolean = 'and', bool $not = false): static + { + $type = 'JsonContainsKey'; + + $this->wheres[] = compact('type', 'column', 'boolean', 'not'); + + return $this; + } + + /** + * Add an "or" clause that determines if a JSON path exists to the query. + */ + public function orWhereJsonContainsKey(string $column): static + { + return $this->whereJsonContainsKey($column, 'or'); + } + + /** + * Add a clause that determines if a JSON path does not exist to the query. + */ + public function whereJsonDoesntContainKey(string $column, string $boolean = 'and'): static + { + return $this->whereJsonContainsKey($column, $boolean, true); + } + + /** + * Add an "or" clause that determines if a JSON path does not exist to the query. + */ + public function orWhereJsonDoesntContainKey(string $column): static + { + return $this->whereJsonDoesntContainKey($column, 'or'); + } + + /** + * Add a "where JSON length" clause to the query. + */ + public function whereJsonLength(string $column, mixed $operator, mixed $value = null, string $boolean = 'and'): static + { + $type = 'JsonLength'; + + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $this->wheres[] = compact('type', 'column', 'operator', 'value', 'boolean'); + + if (! $value instanceof ExpressionContract) { + $this->addBinding((int) $this->flattenValue($value)); + } + + return $this; + } + + /** + * Add an "or where JSON length" clause to the query. + */ + public function orWhereJsonLength(string $column, mixed $operator, mixed $value = null): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + return $this->whereJsonLength($column, $operator, $value, 'or'); + } + + /** + * Handles dynamic "where" clauses to the query. + */ + public function dynamicWhere(string $method, array $parameters): static + { + $finder = substr($method, 5); + + $segments = preg_split( + '/(And|Or)(?=[A-Z])/', + $finder, + -1, + PREG_SPLIT_DELIM_CAPTURE + ); + + // The connector variable will determine which connector will be used for the + // query condition. We will change it as we come across new boolean values + // in the dynamic method strings, which could contain a number of these. + $connector = 'and'; + + $index = 0; + + foreach ($segments as $segment) { + // If the segment is not a boolean connector, we can assume it is a column's name + // and we will add it to the query as a new constraint as a where clause, then + // we can keep iterating through the dynamic method string's segments again. + if ($segment !== 'And' && $segment !== 'Or') { + $this->addDynamic($segment, $connector, $parameters, $index); + + ++$index; + } + + // Otherwise, we will store the connector so we know how the next where clause we + // find in the query should be connected to the previous ones, meaning we will + // have the proper boolean connector to connect the next where clause found. + else { + $connector = $segment; + } + } + + return $this; + } + + /** + * Add a single dynamic "where" clause statement to the query. + */ + protected function addDynamic(string $segment, string $connector, array $parameters, int $index): void + { + // Once we have parsed out the columns and formatted the boolean operators we + // are ready to add it to this query as a where clause just like any other + // clause on the query. Then we'll increment the parameter index values. + $bool = strtolower($connector); + + $this->where(StrCache::snake($segment), '=', $parameters[$index], $bool); + } + + /** + * Add a "where fulltext" clause to the query. + */ + public function whereFullText(string|array $columns, string $value, array $options = [], string $boolean = 'and'): static + { + $type = 'Fulltext'; + + $columns = (array) $columns; + + $this->wheres[] = compact('type', 'columns', 'value', 'options', 'boolean'); + + $this->addBinding($value); + + return $this; + } + + /** + * Add an "or where fulltext" clause to the query. + */ + public function orWhereFullText(string|array $columns, string $value, array $options = []): static + { + return $this->whereFullText($columns, $value, $options, 'or'); + } + + /** + * Add a "where" clause to the query for multiple columns with "and" conditions between them. + * + * @param array $columns + */ + public function whereAll(array $columns, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + $this->whereNested(function ($query) use ($columns, $operator, $value) { + foreach ($columns as $column) { + $query->where($column, $operator, $value, 'and'); + } + }, $boolean); + + return $this; + } + + /** + * Add an "or where" clause to the query for multiple columns with "and" conditions between them. + * + * @param array $columns + */ + public function orWhereAll(array $columns, mixed $operator = null, mixed $value = null): static + { + return $this->whereAll($columns, $operator, $value, 'or'); + } + + /** + * Add a "where" clause to the query for multiple columns with "or" conditions between them. + * + * @param array $columns + */ + public function whereAny(array $columns, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + $this->whereNested(function ($query) use ($columns, $operator, $value) { + foreach ($columns as $column) { + $query->where($column, $operator, $value, 'or'); + } + }, $boolean); + + return $this; + } + + /** + * Add an "or where" clause to the query for multiple columns with "or" conditions between them. + * + * @param array $columns + */ + public function orWhereAny(array $columns, mixed $operator = null, mixed $value = null): static + { + return $this->whereAny($columns, $operator, $value, 'or'); + } + + /** + * Add a "where not" clause to the query for multiple columns where none of the conditions should be true. + * + * @param array $columns + */ + public function whereNone(array $columns, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static + { + return $this->whereAny($columns, $operator, $value, $boolean . ' not'); + } + + /** + * Add an "or where not" clause to the query for multiple columns where none of the conditions should be true. + * + * @param array $columns + */ + public function orWhereNone(array $columns, mixed $operator = null, mixed $value = null): static + { + return $this->whereNone($columns, $operator, $value, 'or'); + } + + /** + * Add a "group by" clause to the query. + */ + public function groupBy(array|ExpressionContract|string ...$groups): static + { + foreach ($groups as $group) { + $this->groups = array_merge( + (array) $this->groups, + Arr::wrap($group) + ); + } + + return $this; + } + + /** + * Add a raw "groupBy" clause to the query. + */ + public function groupByRaw(string $sql, array $bindings = []): static + { + $this->groups[] = new Expression($sql); + + $this->addBinding($bindings, 'groupBy'); + + return $this; + } + + /** + * Add a "having" clause to the query. + */ + public function having( + ExpressionContract|Closure|string $column, + DateTimeInterface|string|int|float|null $operator = null, + ExpressionContract|DateTimeInterface|string|int|float|null $value = null, + string $boolean = 'and', + ): static { + $type = 'Basic'; + + if ($column instanceof ConditionExpression) { + $type = 'Expression'; + + $this->havings[] = compact('type', 'column', 'boolean'); + + return $this; + } + + // Here we will make some assumptions about the operator. If only 2 values are + // passed to the method, we will assume that the operator is an equals sign + // and keep going. Otherwise, we'll require the operator to be passed in. + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + if ($column instanceof Closure && is_null($operator)) { + return $this->havingNested($column, $boolean); + } + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + if ($this->isBitwiseOperator($operator)) { + $type = 'Bitwise'; + } + + $this->havings[] = compact('type', 'column', 'operator', 'value', 'boolean'); + + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->flattenValue($value), 'having'); + } + + return $this; + } + + /** + * Add an "or having" clause to the query. + */ + public function orHaving( + ExpressionContract|Closure|string $column, + DateTimeInterface|string|int|float|null $operator = null, + ExpressionContract|DateTimeInterface|string|int|float|null $value = null, + ): static { + [$value, $operator] = $this->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + return $this->having($column, $operator, $value, 'or'); + } + + /** + * Add a nested "having" statement to the query. + */ + public function havingNested(Closure $callback, string $boolean = 'and'): static + { + $callback($query = $this->forNestedWhere()); + + return $this->addNestedHavingQuery($query, $boolean); + } + + /** + * Add another query builder as a nested having to the query builder. + */ + public function addNestedHavingQuery(self $query, string $boolean = 'and'): static + { + if (count($query->havings)) { + $type = 'Nested'; + + $this->havings[] = compact('type', 'query', 'boolean'); + + $this->addBinding($query->getRawBindings()['having'], 'having'); + } + + return $this; + } + + /** + * Add a "having null" clause to the query. + */ + public function havingNull(array|string $columns, string $boolean = 'and', bool $not = false): static + { + $type = $not ? 'NotNull' : 'Null'; + + foreach (Arr::wrap($columns) as $column) { + $this->havings[] = compact('type', 'column', 'boolean'); + } + + return $this; + } + + /** + * Add an "or having null" clause to the query. + */ + public function orHavingNull(string $column): static + { + return $this->havingNull($column, 'or'); + } + + /** + * Add a "having not null" clause to the query. + */ + public function havingNotNull(array|string $columns, string $boolean = 'and'): static + { + return $this->havingNull($columns, $boolean, true); + } + + /** + * Add an "or having not null" clause to the query. + */ + public function orHavingNotNull(string $column): static + { + return $this->havingNotNull($column, 'or'); + } + + /** + * Add a "having between" clause to the query. + */ + public function havingBetween(string $column, iterable $values, string $boolean = 'and', bool $not = false): static + { + $type = 'between'; + + if ($values instanceof CarbonPeriod) { + $values = [$values->getStartDate(), $values->getEndDate()]; + } + + $this->havings[] = compact('type', 'column', 'values', 'boolean', 'not'); + + $this->addBinding(array_slice($this->cleanBindings(Arr::flatten($values)), 0, 2), 'having'); + + return $this; + } + + /** + * Add a "having not between" clause to the query. + */ + public function havingNotBetween(string $column, iterable $values, string $boolean = 'and'): static + { + return $this->havingBetween($column, $values, $boolean, true); + } + + /** + * Add an "or having between" clause to the query. + */ + public function orHavingBetween(string $column, iterable $values): static + { + return $this->havingBetween($column, $values, 'or'); + } + + /** + * Add an "or having not between" clause to the query. + */ + public function orHavingNotBetween(string $column, iterable $values): static + { + return $this->havingBetween($column, $values, 'or', true); + } + + /** + * Add a raw "having" clause to the query. + */ + public function havingRaw(string $sql, array $bindings = [], string $boolean = 'and'): static + { + $type = 'Raw'; + + $this->havings[] = compact('type', 'sql', 'boolean'); + + $this->addBinding($bindings, 'having'); + + return $this; + } + + /** + * Add a raw "or having" clause to the query. + */ + public function orHavingRaw(string $sql, array $bindings = []): static + { + return $this->havingRaw($sql, $bindings, 'or'); + } + + /** + * Add an "order by" clause to the query. + * + * @param Closure|self|EloquentBuilder<*>|ExpressionContract|string $column + * + * @throws InvalidArgumentException + */ + public function orderBy(Closure|self|EloquentBuilder|ExpressionContract|string $column, string $direction = 'asc'): static + { + if ($this->isQueryable($column)) { + [$query, $bindings] = $this->createSub($column); + + $column = new Expression('(' . $query . ')'); + + $this->addBinding($bindings, $this->unions ? 'unionOrder' : 'order'); + } + + $direction = strtolower($direction); + + if (! in_array($direction, ['asc', 'desc'], true)) { + throw new InvalidArgumentException('Order direction must be "asc" or "desc".'); + } + + $this->{$this->unions ? 'unionOrders' : 'orders'}[] = [ + 'column' => $column, + 'direction' => $direction, + ]; + + return $this; + } + + /** + * Add a descending "order by" clause to the query. + * + * @param Closure|self|EloquentBuilder<*>|ExpressionContract|string $column + */ + public function orderByDesc(Closure|self|EloquentBuilder|ExpressionContract|string $column): static + { + return $this->orderBy($column, 'desc'); + } + + /** + * Add an "order by" clause for a timestamp to the query. + */ + public function latest(Closure|self|ExpressionContract|string $column = 'created_at'): static + { + return $this->orderBy($column, 'desc'); + } + + /** + * Add an "order by" clause for a timestamp to the query. + */ + public function oldest(Closure|self|ExpressionContract|string $column = 'created_at'): static + { + return $this->orderBy($column, 'asc'); + } + + /** + * Add a vector-distance "order by" clause to the query. + * + * @param array|Arrayable|Collection|string $vector + */ + public function orderByVectorDistance(ExpressionContract|string $column, Collection|Arrayable|array|string $vector): static + { + $this->ensureConnectionSupportsVectors(); + + if (is_string($vector)) { + $vector = Str::of($vector)->toEmbeddings(cache: true); + } + + $this->addBinding( + json_encode( + $vector instanceof Arrayable + ? $vector->toArray() + : $vector, + flags: JSON_THROW_ON_ERROR + ), + $this->unions ? 'unionOrder' : 'order' + ); + + $this->{$this->unions ? 'unionOrders' : 'orders'}[] = [ + 'column' => new Expression("({$this->getGrammar()->wrap($column)} <=> ?)"), + 'direction' => 'asc', + ]; + + return $this; + } + + /** + * Put the query's results in random order. + */ + public function inRandomOrder(string|int $seed = ''): static + { + return $this->orderByRaw($this->grammar->compileRandom($seed)); + } + + /** + * Add a raw "order by" clause to the query. + */ + public function orderByRaw(string $sql, array $bindings = []): static + { + $type = 'Raw'; + + $this->{$this->unions ? 'unionOrders' : 'orders'}[] = compact('type', 'sql'); + + $this->addBinding($bindings, $this->unions ? 'unionOrder' : 'order'); + + return $this; + } + + /** + * Alias to set the "offset" value of the query. + */ + public function skip(int $value): static + { + return $this->offset($value); + } + + /** + * Set the "offset" value of the query. + */ + public function offset(int $value): static + { + $property = $this->unions ? 'unionOffset' : 'offset'; + + $this->{$property} = max(0, $value); + + return $this; + } + + /** + * Alias to set the "limit" value of the query. + */ + public function take(int $value): static + { + return $this->limit($value); + } + + /** + * Set the "limit" value of the query. + */ + public function limit(int $value): static + { + $property = $this->unions ? 'unionLimit' : 'limit'; + + if ($value >= 0) { + $this->{$property} = $value; + } + + return $this; + } + + /** + * Add a "group limit" clause to the query. + */ + public function groupLimit(int $value, string $column): static + { + if ($value >= 0) { + $this->groupLimit = compact('value', 'column'); + } + + return $this; + } + + /** + * Set the limit and offset for a given page. + */ + public function forPage(int $page, int $perPage = 15): static + { + return $this->offset(($page - 1) * $perPage)->limit($perPage); + } + + /** + * Constrain the query to the previous "page" of results before a given ID. + */ + public function forPageBeforeId(int $perPage = 15, ?int $lastId = 0, string $column = 'id'): static + { + $this->orders = $this->removeExistingOrdersFor($column); + + if (is_null($lastId)) { + $this->whereNotNull($column); + } else { + $this->where($column, '<', $lastId); + } + + return $this->orderBy($column, 'desc') + ->limit($perPage); + } + + /** + * Constrain the query to the next "page" of results after a given ID. + */ + public function forPageAfterId(int $perPage = 15, string|int|null $lastId = 0, string $column = 'id'): static + { + $this->orders = $this->removeExistingOrdersFor($column); + + if (is_null($lastId)) { + $this->whereNotNull($column); + } else { + $this->where($column, '>', $lastId); + } + + return $this->orderBy($column, 'asc') + ->limit($perPage); + } + + /** + * Remove all existing orders and optionally add a new order. + */ + public function reorder(Closure|self|ExpressionContract|string|null $column = null, string $direction = 'asc'): static + { + $this->orders = null; + $this->unionOrders = null; + $this->bindings['order'] = []; + $this->bindings['unionOrder'] = []; + + if ($column) { + return $this->orderBy($column, $direction); + } + + return $this; + } + + /** + * Add descending "reorder" clause to the query. + */ + public function reorderDesc(Closure|self|ExpressionContract|string|null $column): static + { + return $this->reorder($column, 'desc'); + } + + /** + * Get an array with all orders with a given column removed. + */ + protected function removeExistingOrdersFor(string $column): array + { + return (new Collection($this->orders)) + ->reject(fn ($order) => isset($order['column']) && $order['column'] === $column) + ->values() + ->all(); + } + + /** + * Add a "union" statement to the query. + * + * @param Closure|self|EloquentBuilder<*> $query + */ + public function union(Closure|self|EloquentBuilder $query, bool $all = false): static + { + if ($query instanceof Closure) { + $query($query = $this->newQuery()); + } + + $this->unions[] = compact('query', 'all'); + + $this->addBinding($query->getBindings(), 'union'); + + return $this; + } + + /** + * Add a "union all" statement to the query. + * + * @param Closure|self|EloquentBuilder<*> $query + */ + public function unionAll(Closure|self|EloquentBuilder $query): static + { + return $this->union($query, true); + } + + /** + * Lock the selected rows in the table. + */ + public function lock(string|bool $value = true): static + { + $this->lock = $value; + $this->useWritePdo(); + + return $this; + } + + /** + * Lock the selected rows in the table for updating. + */ + public function lockForUpdate(): static + { + return $this->lock(true); + } + + /** + * Share lock the selected rows in the table. + */ + public function sharedLock(): static + { + return $this->lock(false); + } + + /** + * Register a closure to be invoked before the query is executed. + */ + public function beforeQuery(callable $callback): static + { + $this->beforeQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "before query" modification callbacks. + */ + public function applyBeforeQueryCallbacks(): void + { + foreach ($this->beforeQueryCallbacks as $callback) { + $callback($this); + } + + $this->beforeQueryCallbacks = []; + } + + /** + * Register a closure to be invoked after the query is executed. + */ + public function afterQuery(Closure $callback): static + { + $this->afterQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "after query" modification callbacks. + */ + public function applyAfterQueryCallbacks(mixed $result): mixed + { + foreach ($this->afterQueryCallbacks as $afterQueryCallback) { + $result = $afterQueryCallback($result) ?: $result; + } + + return $result; + } + + /** + * Get the SQL representation of the query. + */ + public function toSql(): string + { + $this->applyBeforeQueryCallbacks(); + + return $this->grammar->compileSelect($this); + } + + /** + * Get the raw SQL representation of the query with embedded bindings. + */ + public function toRawSql(): string + { + return $this->grammar->substituteBindingsIntoRawSql( + $this->toSql(), + $this->connection->prepareBindings($this->getBindings()) + ); + } + + /** + * Execute a query for a single record by ID. + * + * @param array|ExpressionContract|string $columns + */ + public function find(int|string $id, ExpressionContract|array|string $columns = ['*']): ?stdClass + { + return $this->where('id', '=', $id)->first($columns); + } + + /** + * Execute a query for a single record by ID or call a callback. + * + * @template TValue + * + * @param array|(Closure(): TValue)|ExpressionContract|string $columns + * @param null|(Closure(): TValue) $callback + * @return stdClass|TValue + */ + public function findOr(mixed $id, Closure|ExpressionContract|array|string $columns = ['*'], ?Closure $callback = null): mixed + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($data = $this->find($id, $columns))) { + return $data; + } + + return $callback(); + } + + /** + * Get a single column's value from the first result of a query. + */ + public function value(string $column): mixed + { + $result = (array) $this->first([$column]); + + return count($result) > 0 ? Arr::first($result) : null; + } + + /** + * Get a single expression value from the first result of a query. + */ + public function rawValue(string $expression, array $bindings = []): mixed + { + $result = (array) $this->selectRaw($expression, $bindings)->first(); + + return count($result) > 0 ? Arr::first($result) : null; + } + + /** + * Get a single column's value from the first result of a query if it's the sole matching record. + * + * @throws \Hypervel\Database\RecordsNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function soleValue(string $column): mixed + { + $result = (array) $this->sole([$column]); + + return Arr::first($result); + } + + /** + * Execute the query as a "select" statement. + * + * @param array|ExpressionContract|string $columns + * @return Collection + */ + public function get(ExpressionContract|array|string $columns = ['*']): Collection + { + $items = new Collection($this->onceWithColumns(Arr::wrap($columns), function () { + return $this->processor->processSelect($this, $this->runSelect()); + })); + + return $this->applyAfterQueryCallbacks( + isset($this->groupLimit) ? $this->withoutGroupLimitKeys($items) : $items + ); + } + + /** + * Run the query as a "select" statement against the connection. + */ + protected function runSelect(): array + { + return $this->connection->select( + $this->toSql(), + $this->getBindings(), + ! $this->useWritePdo + ); + } + + /** + * Remove the group limit keys from the results in the collection. + */ + protected function withoutGroupLimitKeys(Collection $items): Collection + { + $keysToRemove = ['laravel_row']; + + if (is_string($this->groupLimit['column'])) { + $column = last(explode('.', $this->groupLimit['column'])); + + $keysToRemove[] = '@laravel_group := ' . $this->grammar->wrap($column); + $keysToRemove[] = '@laravel_group := ' . $this->grammar->wrap('pivot_' . $column); + } + + $items->each(function ($item) use ($keysToRemove) { + foreach ($keysToRemove as $key) { + unset($item->{$key}); + } + }); + + return $items; + } + + /** + * Paginate the given query into a simple paginator. + * + * @param array|ExpressionContract|string $columns + */ + public function paginate( + int|Closure $perPage = 15, + ExpressionContract|array|string $columns = ['*'], + string $pageName = 'page', + ?int $page = null, + Closure|int|null $total = null, + ): LengthAwarePaginator { + $page = $page ?: Paginator::resolveCurrentPage($pageName); + + $total = value($total) ?? $this->getCountForPagination(); + + $perPage = value($perPage, $total); + + $results = $total ? $this->forPage($page, $perPage)->get($columns) : new Collection(); + + return $this->paginator($results, $total, $perPage, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ]); + } + + /** + * Get a paginator only supporting simple next and previous links. + * + * This is more efficient on larger data-sets, etc. + * + * @param array|ExpressionContract|string $columns + */ + public function simplePaginate( + int $perPage = 15, + ExpressionContract|array|string $columns = ['*'], + string $pageName = 'page', + ?int $page = null, + ): PaginatorContract { + $page = $page ?: Paginator::resolveCurrentPage($pageName); + + $this->offset(($page - 1) * $perPage)->limit($perPage + 1); + + return $this->simplePaginator($this->get($columns), $perPage, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ]); + } + + /** + * Get a paginator only supporting simple next and previous links. + * + * This is more efficient on larger data-sets, etc. + * + * @param array|ExpressionContract|string $columns + */ + public function cursorPaginate( + ?int $perPage = 15, + ExpressionContract|array|string $columns = ['*'], + string $cursorName = 'cursor', + Cursor|string|null $cursor = null, + ): CursorPaginatorContract { + return $this->paginateUsingCursor($perPage, $columns, $cursorName, $cursor); + } + + /** + * Ensure the proper order by required for cursor pagination. + */ + protected function ensureOrderForCursorPagination(bool $shouldReverse = false): Collection + { + if (empty($this->orders) && empty($this->unionOrders)) { + $this->enforceOrderBy(); + } + + $reverseDirection = function ($order) { + if (! isset($order['direction'])) { + return $order; + } + + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; + + return $order; + }; + + if ($shouldReverse) { + $this->orders = (new Collection($this->orders))->map($reverseDirection)->toArray(); + $this->unionOrders = (new Collection($this->unionOrders))->map($reverseDirection)->toArray(); + } + + $orders = ! empty($this->unionOrders) ? $this->unionOrders : $this->orders; + + return (new Collection($orders)) + ->filter(fn ($order) => Arr::has($order, 'direction')) + ->values(); + } + + /** + * Get the count of the total records for the paginator. + * + * @param array $columns + * @return int<0, max> + */ + public function getCountForPagination(array $columns = ['*']): int + { + $results = $this->runPaginationCountQuery($columns); + + // Once we have run the pagination count query, we will get the resulting count and + // take into account what type of query it was. When there is a group by we will + // just return the count of the entire results set since that will be correct. + if (! isset($results[0])) { + return 0; + } + if (is_object($results[0])) { + return (int) $results[0]->aggregate; + } + + return (int) array_change_key_case((array) $results[0])['aggregate']; + } + + /** + * Run a pagination count query. + * + * @param array $columns + * @return array + */ + protected function runPaginationCountQuery(array $columns = ['*']): array + { + if ($this->groups || $this->havings) { + $clone = $this->cloneForPaginationCount(); + + if (is_null($clone->columns) && ! empty($this->joins)) { + $clone->select($this->from . '.*'); + } + + return $this->newQuery() + ->from(new Expression('(' . $clone->toSql() . ') as ' . $this->grammar->wrap('aggregate_table'))) + ->mergeBindings($clone) + ->setAggregate('count', $this->withoutSelectAliases($columns)) + ->get()->all(); + } + + $without = $this->unions ? ['unionOrders', 'unionLimit', 'unionOffset'] : ['columns', 'orders', 'limit', 'offset']; + + return $this->cloneWithout($without) + ->cloneWithoutBindings($this->unions ? ['unionOrder'] : ['select', 'order']) + ->setAggregate('count', $this->withoutSelectAliases($columns)) + ->get()->all(); + } + + /** + * Clone the existing query instance for usage in a pagination subquery. + */ + protected function cloneForPaginationCount(): self + { + return $this->cloneWithout(['orders', 'limit', 'offset']) + ->cloneWithoutBindings(['order']); + } + + /** + * Remove the column aliases since they will break count queries. + * + * @param array $columns + * @return array + */ + protected function withoutSelectAliases(array $columns): array + { + return array_map(function ($column) { + return is_string($column) && ($aliasPosition = stripos($column, ' as ')) !== false + ? substr($column, 0, $aliasPosition) + : $column; + }, $columns); + } + + /** + * Get a lazy collection for the given query. + * + * @return LazyCollection + */ + public function cursor(): LazyCollection + { + if (is_null($this->columns)) { + $this->columns = ['*']; + } + + return (new LazyCollection(function () { + yield from $this->connection->cursor( + $this->toSql(), + $this->getBindings(), + ! $this->useWritePdo + ); + }))->map(function ($item) { + return $this->applyAfterQueryCallbacks(new Collection([$item]))->first(); + })->reject(fn ($item) => is_null($item)); + } + + /** + * Throw an exception if the query doesn't have an orderBy clause. + * + * @throws RuntimeException + */ + protected function enforceOrderBy(): void + { + if (empty($this->orders) && empty($this->unionOrders)) { + throw new RuntimeException('You must specify an orderBy clause when using this function.'); + } + } + + /** + * Get a collection instance containing the values of a given column. + * + * @return Collection + */ + public function pluck(ExpressionContract|string $column, ?string $key = null): Collection + { + // First, we will need to select the results of the query accounting for the + // given columns / key. Once we have the results, we will be able to take + // the results and get the exact data that was requested for the query. + $queryResult = $this->onceWithColumns( + is_null($key) || $key === $column ? [$column] : [$column, $key], + function () { + return $this->processor->processSelect( + $this, + $this->runSelect() + ); + } + ); + + if (empty($queryResult)) { + return new Collection(); + } + + // If the columns are qualified with a table or have an alias, we cannot use + // those directly in the "pluck" operations since the results from the DB + // are only keyed by the column itself. We'll strip the table out here. + $column = $this->stripTableForPluck($column); + + $key = $this->stripTableForPluck($key); + + return $this->applyAfterQueryCallbacks( + is_array($queryResult[0]) + ? $this->pluckFromArrayColumn($queryResult, $column, $key) + : $this->pluckFromObjectColumn($queryResult, $column, $key) + ); + } + + /** + * Strip off the table name or alias from a column identifier. + */ + protected function stripTableForPluck(ExpressionContract|string|null $column): ?string + { + if (is_null($column)) { + return $column; + } + + $columnString = $column instanceof ExpressionContract + ? $this->grammar->getValue($column) + : $column; + + $separator = str_contains(strtolower($columnString), ' as ') ? ' as ' : '\.'; + + return last(preg_split('~' . $separator . '~i', $columnString)); + } + + /** + * Retrieve column values from rows represented as objects. + */ + protected function pluckFromObjectColumn(array $queryResult, string $column, ?string $key): Collection + { + $results = []; + + if (is_null($key)) { + foreach ($queryResult as $row) { + $results[] = $row->{$column}; + } + } else { + foreach ($queryResult as $row) { + $results[$row->{$key}] = $row->{$column}; + } + } + + return new Collection($results); + } + + /** + * Retrieve column values from rows represented as arrays. + */ + protected function pluckFromArrayColumn(array $queryResult, string $column, ?string $key): Collection + { + $results = []; + + if (is_null($key)) { + foreach ($queryResult as $row) { + $results[] = $row[$column]; + } + } else { + foreach ($queryResult as $row) { + $results[$row[$key]] = $row[$column]; + } + } + + return new Collection($results); + } + + /** + * Concatenate values of a given column as a string. + */ + public function implode(string $column, string $glue = ''): string + { + return $this->pluck($column)->implode($glue); + } + + /** + * Determine if any rows exist for the current query. + */ + public function exists(): bool + { + $this->applyBeforeQueryCallbacks(); + + $results = $this->connection->select( + $this->grammar->compileExists($this), + $this->getBindings(), + ! $this->useWritePdo + ); + + // If the results have rows, we will get the row and see if the exists column is a + // boolean true. If there are no results for this query we will return false as + // there are no rows for this query at all, and we can return that info here. + if (isset($results[0])) { + $results = (array) $results[0]; + + return (bool) $results['exists']; + } + + return false; + } + + /** + * Determine if no rows exist for the current query. + */ + public function doesntExist(): bool + { + return ! $this->exists(); + } + + /** + * Execute the given callback if no rows exist for the current query. + */ + public function existsOr(Closure $callback): mixed + { + return $this->exists() ? true : $callback(); + } + + /** + * Execute the given callback if rows exist for the current query. + */ + public function doesntExistOr(Closure $callback): mixed + { + return $this->doesntExist() ? true : $callback(); + } + + /** + * Retrieve the "count" result of the query. + * + * @return int<0, max> + */ + public function count(ExpressionContract|string $columns = '*'): int + { + return (int) $this->aggregate(__FUNCTION__, Arr::wrap($columns)); + } + + /** + * Retrieve the minimum value of a given column. + */ + public function min(ExpressionContract|string $column): mixed + { + return $this->aggregate(__FUNCTION__, [$column]); + } + + /** + * Retrieve the maximum value of a given column. + */ + public function max(ExpressionContract|string $column): mixed + { + return $this->aggregate(__FUNCTION__, [$column]); + } + + /** + * Retrieve the sum of the values of a given column. + */ + public function sum(ExpressionContract|string $column): mixed + { + $result = $this->aggregate(__FUNCTION__, [$column]); + + return $result ?: 0; + } + + /** + * Retrieve the average of the values of a given column. + */ + public function avg(ExpressionContract|string $column): mixed + { + return $this->aggregate(__FUNCTION__, [$column]); + } + + /** + * Alias for the "avg" method. + */ + public function average(ExpressionContract|string $column): mixed + { + return $this->avg($column); + } + + /** + * Execute an aggregate function on the database. + */ + public function aggregate(string $function, array $columns = ['*']): mixed + { + $results = $this->cloneWithout($this->unions || $this->havings ? [] : ['columns']) + ->cloneWithoutBindings($this->unions || $this->havings ? [] : ['select']) + ->setAggregate($function, $columns) + ->get($columns); + + if (! $results->isEmpty()) { + return array_change_key_case((array) $results[0])['aggregate']; + } + + return null; + } + + /** + * Execute a numeric aggregate function on the database. + */ + public function numericAggregate(string $function, array $columns = ['*']): float|int + { + $result = $this->aggregate($function, $columns); + + // If there is no result, we can obviously just return 0 here. Next, we will check + // if the result is an integer or float. If it is already one of these two data + // types we can just return the result as-is, otherwise we will convert this. + if (! $result) { + return 0; + } + + if (is_int($result) || is_float($result)) { + return $result; + } + + // If the result doesn't contain a decimal place, we will assume it is an int then + // cast it to one. When it does we will cast it to a float since it needs to be + // cast to the expected data type for the developers out of pure convenience. + return ! str_contains((string) $result, '.') + ? (int) $result + : (float) $result; + } + + /** + * Set the aggregate property without running the query. + * + * @param array $columns + */ + protected function setAggregate(string $function, array $columns): static + { + $this->aggregate = compact('function', 'columns'); + + if (empty($this->groups)) { + $this->orders = null; + + $this->bindings['order'] = []; + } + + return $this; + } + + /** + * Execute the given callback while selecting the given columns. + * + * After running the callback, the columns are reset to the original value. + * + * @template TResult + * + * @param array $columns + * @param callable(): TResult $callback + * @return TResult + */ + protected function onceWithColumns(array $columns, callable $callback): mixed + { + $original = $this->columns; + + if (is_null($original)) { + $this->columns = $columns; + } + + $result = $callback(); + + $this->columns = $original; + + return $result; + } + + /** + * Insert new records into the database. + */ + public function insert(array $values): bool + { + // Since every insert gets treated like a batch insert, we will make sure the + // bindings are structured in a way that is convenient when building these + // inserts statements by verifying these elements are actually an array. + if (empty($values)) { + return true; + } + + if (! is_array(Arr::first($values))) { + $values = [$values]; + } + + // Here, we will sort the insert keys for every record so that each insert is + // in the same order for the record. We need to make sure this is the case + // so there are not any errors or problems when inserting these records. + else { + foreach ($values as $key => $value) { + ksort($value); + + $values[$key] = $value; + } + } + + $this->applyBeforeQueryCallbacks(); + + // Finally, we will run this query against the database connection and return + // the results. We will need to also flatten these bindings before running + // the query so they are all in one huge, flattened array for execution. + return $this->connection->insert( + $this->grammar->compileInsert($this, $values), + $this->cleanBindings(Arr::flatten($values, 1)) + ); + } + + /** + * Insert new records into the database while ignoring errors. + * + * @return int<0, max> + */ + public function insertOrIgnore(array $values): int + { + if (empty($values)) { + return 0; + } + + if (! is_array(Arr::first($values))) { + $values = [$values]; + } else { + foreach ($values as $key => $value) { + ksort($value); + + $values[$key] = $value; + } + } + + $this->applyBeforeQueryCallbacks(); + + return $this->connection->affectingStatement( + $this->grammar->compileInsertOrIgnore($this, $values), + $this->cleanBindings(Arr::flatten($values, 1)) + ); + } + + /** + * Insert a new record and get the value of the primary key. + */ + public function insertGetId(array $values, ?string $sequence = null): int + { + $this->applyBeforeQueryCallbacks(); + + $sql = $this->grammar->compileInsertGetId($this, $values, $sequence); + + $values = $this->cleanBindings($values); + + return $this->processor->processInsertGetId($this, $sql, $values, $sequence); + } + + /** + * Insert new records into the table using a subquery. + * + * @param Closure|self|EloquentBuilder<*>|string $query + */ + public function insertUsing(array $columns, Closure|self|EloquentBuilder|string $query): int + { + $this->applyBeforeQueryCallbacks(); + + [$sql, $bindings] = $this->createSub($query); + + return $this->connection->affectingStatement( + $this->grammar->compileInsertUsing($this, $columns, $sql), + $this->cleanBindings($bindings) + ); + } + + /** + * Insert new records into the table using a subquery while ignoring errors. + * + * @param Closure|self|EloquentBuilder<*>|string $query + */ + public function insertOrIgnoreUsing(array $columns, Closure|self|EloquentBuilder|string $query): int + { + $this->applyBeforeQueryCallbacks(); + + [$sql, $bindings] = $this->createSub($query); + + return $this->connection->affectingStatement( + $this->grammar->compileInsertOrIgnoreUsing($this, $columns, $sql), + $this->cleanBindings($bindings) + ); + } + + /** + * Update records in the database. + * + * @return int<0, max> + */ + public function update(array $values): int + { + $this->applyBeforeQueryCallbacks(); + + $values = (new Collection($values))->map(function ($value) { + if (! $value instanceof Builder) { + return ['value' => $value, 'bindings' => match (true) { + $value instanceof Collection => $value->all(), + $value instanceof UnitEnum => enum_value($value), + default => $value, + }]; + } + + [$query, $bindings] = $this->parseSub($value); + + return ['value' => new Expression("({$query})"), 'bindings' => fn () => $bindings]; + }); + + $sql = $this->grammar->compileUpdate($this, $values->map(fn ($value) => $value['value'])->all()); + + return $this->connection->update($sql, $this->cleanBindings( + $this->grammar->prepareBindingsForUpdate($this->bindings, $values->map(fn ($value) => $value['bindings'])->all()) + )); + } + + /** + * Update records in a PostgreSQL database using the update from syntax. + */ + public function updateFrom(array $values): int + { + if (! method_exists($this->grammar, 'compileUpdateFrom')) { + throw new LogicException('This database engine does not support the updateFrom method.'); + } + + $this->applyBeforeQueryCallbacks(); + + // @phpstan-ignore method.notFound (driver-specific method checked by method_exists above) + $sql = $this->grammar->compileUpdateFrom($this, $values); + + return $this->connection->update($sql, $this->cleanBindings( + // @phpstan-ignore method.notFound (driver-specific method checked by method_exists above) + $this->grammar->prepareBindingsForUpdateFrom($this->bindings, $values) + )); + } + + /** + * Insert or update a record matching the attributes, and fill it with values. + */ + public function updateOrInsert(array $attributes, array|callable $values = []): bool + { + $exists = $this->where($attributes)->exists(); + + if ($values instanceof Closure) { + $values = $values($exists); + } + + if (! $exists) { + return $this->insert(array_merge($attributes, $values)); + } + + if (empty($values)) { + return true; + } + + return (bool) $this->limit(1)->update($values); + } + + /** + * Insert new records or update the existing ones. + */ + public function upsert(array $values, array|string $uniqueBy, ?array $update = null): int + { + if (empty($values)) { + return 0; + } + if ($update === []) { + return (int) $this->insert($values); + } + + if (! is_array(Arr::first($values))) { + $values = [$values]; + } else { + foreach ($values as $key => $value) { + ksort($value); + + $values[$key] = $value; + } + } + + if (is_null($update)) { + $update = array_keys(Arr::first($values)); + } + + $this->applyBeforeQueryCallbacks(); + + $bindings = $this->cleanBindings(array_merge( + Arr::flatten($values, 1), + (new Collection($update)) + ->reject(fn ($value, $key) => is_int($key)) + ->all() + )); + + return $this->connection->affectingStatement( + $this->grammar->compileUpsert($this, $values, (array) $uniqueBy, $update), + $bindings + ); + } + + /** + * Increment a column's value by a given amount. + * + * @return int<0, max> + * + * @throws InvalidArgumentException + */ + public function increment(string $column, float|int $amount = 1, array $extra = []): int + { + return $this->incrementEach([$column => $amount], $extra); + } + + /** + * Increment the given column's values by the given amounts. + * + * @param array $columns + * @param array $extra + * @return int<0, max> + * + * @throws InvalidArgumentException + */ + public function incrementEach(array $columns, array $extra = []): int + { + foreach ($columns as $column => $amount) { + $columns[$column] = $this->raw("{$this->grammar->wrap($column)} + {$amount}"); + } + + return $this->update(array_merge($columns, $extra)); + } + + /** + * Decrement a column's value by a given amount. + * + * @return int<0, max> + * + * @throws InvalidArgumentException + */ + public function decrement(string $column, float|int $amount = 1, array $extra = []): int + { + return $this->decrementEach([$column => $amount], $extra); + } + + /** + * Decrement the given column's values by the given amounts. + * + * @param array $columns + * @param array $extra + * @return int<0, max> + * + * @throws InvalidArgumentException + */ + public function decrementEach(array $columns, array $extra = []): int + { + foreach ($columns as $column => $amount) { + $columns[$column] = $this->raw("{$this->grammar->wrap($column)} - {$amount}"); + } + + return $this->update(array_merge($columns, $extra)); + } + + /** + * Delete records from the database. + */ + public function delete(mixed $id = null): int + { + // If an ID is passed to the method, we will set the where clause to check the + // ID to let developers to simply and quickly remove a single row from this + // database without manually specifying the "where" clauses on the query. + if (! is_null($id)) { + $this->where($this->from . '.id', '=', $id); + } + + $this->applyBeforeQueryCallbacks(); + + return $this->connection->delete( + $this->grammar->compileDelete($this), + $this->cleanBindings( + $this->grammar->prepareBindingsForDelete($this->bindings) + ) + ); + } + + /** + * Run a "truncate" statement on the table. + */ + public function truncate(): void + { + $this->applyBeforeQueryCallbacks(); + + foreach ($this->grammar->compileTruncate($this) as $sql => $bindings) { + $this->connection->statement($sql, $bindings); + } + } + + /** + * Get a new instance of the query builder. + */ + public function newQuery(): self + { + return new static($this->connection, $this->grammar, $this->processor); + } + + /** + * Create a new query instance for a sub-query. + */ + protected function forSubQuery(): self + { + return $this->newQuery(); + } + + /** + * Get all of the query builder's columns in a text-only array with all expressions evaluated. + * + * @return list + */ + public function getColumns(): array + { + return ! is_null($this->columns) + ? array_map(fn ($column) => $this->grammar->getValue($column), $this->columns) + : []; + } + + /** + * Create a raw database expression. + */ + public function raw(mixed $value): ExpressionContract + { + return $this->connection->raw($value); + } + + /** + * Get the query builder instances that are used in the union of the query. + */ + protected function getUnionBuilders(): Collection + { + return isset($this->unions) + ? (new Collection($this->unions))->pluck('query') + : new Collection(); + } + + /** + * Get the "limit" value for the query or null if it's not set. + */ + public function getLimit(): ?int + { + $value = $this->unions ? $this->unionLimit : $this->limit; + + return ! is_null($value) ? (int) $value : null; + } + + /** + * Get the "offset" value for the query or null if it's not set. + */ + public function getOffset(): ?int + { + $value = $this->unions ? $this->unionOffset : $this->offset; + + return ! is_null($value) ? (int) $value : null; + } + + /** + * Get the current query value bindings in a flattened array. + * + * @return list + */ + public function getBindings(): array + { + return Arr::flatten($this->bindings); + } + + /** + * Get the raw array of bindings. + * + * @return array{ + * select: list, + * from: list, + * join: list, + * where: list, + * groupBy: list, + * having: list, + * order: list, + * union: list, + * unionOrder: list, + * } + */ + public function getRawBindings(): array + { + return $this->bindings; + } + + /** + * Set the bindings on the query builder. + * + * @param list $bindings + * @param "from"|"groupBy"|"having"|"join"|"order"|"select"|"union"|"unionOrder"|"where" $type + * + * @throws InvalidArgumentException + */ + public function setBindings(array $bindings, string $type = 'where'): static + { + if (! array_key_exists($type, $this->bindings)) { + throw new InvalidArgumentException("Invalid binding type: {$type}."); + } + + $this->bindings[$type] = $bindings; + + return $this; + } + + /** + * Add a binding to the query. + * + * @param "from"|"groupBy"|"having"|"join"|"order"|"select"|"union"|"unionOrder"|"where" $type + * + * @throws InvalidArgumentException + */ + public function addBinding(mixed $value, string $type = 'where'): static + { + if (! array_key_exists($type, $this->bindings)) { + throw new InvalidArgumentException("Invalid binding type: {$type}."); + } + + if (is_array($value)) { + $this->bindings[$type] = array_values(array_map( + $this->castBinding(...), + array_merge($this->bindings[$type], $value), + )); + } else { + $this->bindings[$type][] = $this->castBinding($value); + } + + return $this; + } + + /** + * Cast the given binding value. + */ + public function castBinding(mixed $value): mixed + { + if ($value instanceof UnitEnum) { + return enum_value($value); + } + + return $value; + } + + /** + * Merge an array of bindings into our bindings. + */ + public function mergeBindings(self $query): static + { + $this->bindings = array_merge_recursive($this->bindings, $query->bindings); + + return $this; + } + + /** + * Remove all of the expressions from a list of bindings. + * + * @param array $bindings + * @return list + */ + public function cleanBindings(array $bindings): array + { + return (new Collection($bindings)) + ->reject(function ($binding) { + return $binding instanceof ExpressionContract; + }) + ->map($this->castBinding(...)) + ->values() + ->all(); + } + + /** + * Get a scalar type value from an unknown type of input. + */ + protected function flattenValue(mixed $value): mixed + { + return is_array($value) ? head(Arr::flatten($value)) : $value; + } + + /** + * Get the default key name of the table. + */ + protected function defaultKeyName(): string + { + return 'id'; + } + + /** + * Get the database connection instance. + */ + public function getConnection(): ConnectionInterface + { + return $this->connection; + } + + /** + * Ensure the database connection supports vector queries. + */ + protected function ensureConnectionSupportsVectors(): void + { + if (! $this->connection instanceof PostgresConnection) { + throw new RuntimeException('Vector distance queries are only supported by Postgres.'); + } + } + + /** + * Get the database query processor instance. + */ + public function getProcessor(): Processor + { + return $this->processor; + } + + /** + * Get the query grammar instance. + */ + public function getGrammar(): Grammar + { + return $this->grammar; + } + + /** + * Use the "write" PDO connection when executing the query. + */ + public function useWritePdo(): static + { + $this->useWritePdo = true; + + return $this; + } + + /** + * Determine if the value is a query builder instance or a Closure. + */ + protected function isQueryable(mixed $value): bool + { + return $value instanceof self + || $value instanceof EloquentBuilder + || $value instanceof Relation + || $value instanceof Closure; + } + + /** + * Clone the query. + */ + public function clone(): static + { + return clone $this; + } + + /** + * Clone the query without the given properties. + */ + public function cloneWithout(array $properties): static + { + return tap($this->clone(), function ($clone) use ($properties) { + foreach ($properties as $property) { + $clone->{$property} = null; + } + }); + } + + /** + * Clone the query without the given bindings. + */ + public function cloneWithoutBindings(array $except): static + { + return tap($this->clone(), function ($clone) use ($except) { + foreach ($except as $type) { + $clone->bindings[$type] = []; + } + }); + } + + /** + * Dump the current SQL and bindings. + */ + public function dump(mixed ...$args): static + { + dump( + $this->toSql(), + $this->getBindings(), + ...$args, + ); + + return $this; + } + + /** + * Dump the raw current SQL with embedded bindings. + */ + public function dumpRawSql(): static + { + dump($this->toRawSql()); + + return $this; + } + + /** + * Die and dump the current SQL and bindings. + */ + public function dd(): never + { + dd($this->toSql(), $this->getBindings()); + } + + /** + * Die and dump the current SQL with embedded bindings. + */ + public function ddRawSql(): never + { + dd($this->toRawSql()); + } + + /** + * Handle dynamic method calls into the method. + * + * @throws BadMethodCallException + */ + public function __call(string $method, array $parameters): mixed + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + if (str_starts_with($method, 'where')) { + return $this->dynamicWhere($method, $parameters); + } + + static::throwBadMethodCallException($method); + } +} diff --git a/src/database/src/Query/Expression.php b/src/database/src/Query/Expression.php new file mode 100644 index 000000000..4f27fc7c5 --- /dev/null +++ b/src/database/src/Query/Expression.php @@ -0,0 +1,34 @@ +value; + } +} diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php new file mode 100755 index 000000000..1cd5b990a --- /dev/null +++ b/src/database/src/Query/Grammars/Grammar.php @@ -0,0 +1,1241 @@ +unions || $query->havings) && $query->aggregate) { + return $this->compileUnionAggregate($query); + } + + // If a "group limit" is in place, we will need to compile the SQL to use a + // different syntax. This primarily supports limits on eager loads using + // Eloquent. We'll also set the columns if they have not been defined. + if (isset($query->groupLimit)) { + if (is_null($query->columns)) { + $query->columns = ['*']; + } + + return $this->compileGroupLimit($query); + } + + // If the query does not have any columns set, we'll set the columns to the + // * character to just get all of the columns from the database. Then we + // can build the query and concatenate all the pieces together as one. + $original = $query->columns; + + if (is_null($query->columns)) { + $query->columns = ['*']; + } + + // To compile the query, we'll spin through each component of the query and + // see if that component exists. If it does we'll just call the compiler + // function for the component which is responsible for making the SQL. + $sql = trim( + $this->concatenate( + $this->compileComponents($query) + ) + ); + + if ($query->unions) { + $sql = $this->wrapUnion($sql) . ' ' . $this->compileUnions($query); + } + + $query->columns = $original; + + return $sql; + } + + /** + * Compile the components necessary for a select clause. + */ + protected function compileComponents(Builder $query): array + { + $sql = []; + + foreach ($this->selectComponents as $component) { + if (isset($query->{$component})) { + $method = 'compile' . ucfirst($component); + + $sql[$component] = $this->{$method}($query, $query->{$component}); + } + } + + return $sql; + } + + /** + * Compile an aggregated select clause. + * + * @param array{function: string, columns: array} $aggregate + */ + protected function compileAggregate(Builder $query, array $aggregate): string + { + $column = $this->columnize($aggregate['columns']); + + // If the query has a "distinct" constraint and we're not asking for all columns + // we need to prepend "distinct" onto the column name so that the query takes + // it into account when it performs the aggregating operations on the data. + if (is_array($query->distinct)) { + $column = 'distinct ' . $this->columnize($query->distinct); + } elseif ($query->distinct && $column !== '*') { + $column = 'distinct ' . $column; + } + + return 'select ' . $aggregate['function'] . '(' . $column . ') as aggregate'; + } + + /** + * Compile the "select *" portion of the query. + */ + protected function compileColumns(Builder $query, array $columns): ?string + { + // If the query is actually performing an aggregating select, we will let that + // compiler handle the building of the select clauses, as it will need some + // more syntax that is best handled by that function to keep things neat. + if (! is_null($query->aggregate)) { + return null; + } + + if ($query->distinct) { + $select = 'select distinct '; + } else { + $select = 'select '; + } + + return $select . $this->columnize($columns); + } + + /** + * Compile the "from" portion of the query. + */ + protected function compileFrom(Builder $query, Expression|string $table): string + { + return 'from ' . $this->wrapTable($table); + } + + /** + * Compile the "join" portions of the query. + */ + protected function compileJoins(Builder $query, array $joins): string + { + return (new Collection($joins))->map(function ($join) use ($query) { + $table = $this->wrapTable($join->table); + + $nestedJoins = is_null($join->joins) ? '' : ' ' . $this->compileJoins($query, $join->joins); + + $tableAndNestedJoins = is_null($join->joins) ? $table : '(' . $table . $nestedJoins . ')'; + + if ($join instanceof JoinLateralClause) { + return $this->compileJoinLateral($join, $tableAndNestedJoins); + } + + return trim("{$join->type} join {$tableAndNestedJoins} {$this->compileWheres($join)}"); + })->implode(' '); + } + + /** + * Compile a "lateral join" clause. + * + * @throws RuntimeException + */ + public function compileJoinLateral(JoinLateralClause $join, string $expression): string + { + throw new RuntimeException('This database engine does not support lateral joins.'); + } + + /** + * Compile the "where" portions of the query. + */ + public function compileWheres(Builder $query): string + { + // Each type of where clause has its own compiler function, which is responsible + // for actually creating the where clauses SQL. This helps keep the code nice + // and maintainable since each clause has a very small method that it uses. + if (! $query->wheres) { + return ''; + } + + // If we actually have some where clauses, we will strip off the first boolean + // operator, which is added by the query builders for convenience so we can + // avoid checking for the first clauses in each of the compilers methods. + return $this->concatenateWhereClauses($query, $this->compileWheresToArray($query)); + } + + /** + * Get an array of all the where clauses for the query. + */ + protected function compileWheresToArray(Builder $query): array + { + return (new Collection($query->wheres)) + ->map(fn ($where) => $where['boolean'] . ' ' . $this->{"where{$where['type']}"}($query, $where)) + ->all(); + } + + /** + * Format the where clause statements into one string. + */ + protected function concatenateWhereClauses(Builder $query, array $sql): string + { + $conjunction = $query instanceof JoinClause ? 'on' : 'where'; + + return $conjunction . ' ' . $this->removeLeadingBoolean(implode(' ', $sql)); + } + + /** + * Compile a raw where clause. + */ + protected function whereRaw(Builder $query, array $where): string + { + return $where['sql'] instanceof Expression ? $where['sql']->getValue($this) : $where['sql']; + } + + /** + * Compile a basic where clause. + */ + protected function whereBasic(Builder $query, array $where): string + { + $value = $this->parameter($where['value']); + + $operator = str_replace('?', '??', $where['operator']); + + return $this->wrap($where['column']) . ' ' . $operator . ' ' . $value; + } + + /** + * Compile a bitwise operator where clause. + */ + protected function whereBitwise(Builder $query, array $where): string + { + return $this->whereBasic($query, $where); + } + + /** + * Compile a "where like" clause. + */ + protected function whereLike(Builder $query, array $where): string + { + if ($where['caseSensitive']) { + throw new RuntimeException('This database engine does not support case sensitive like operations.'); + } + + $where['operator'] = $where['not'] ? 'not like' : 'like'; + + return $this->whereBasic($query, $where); + } + + /** + * Compile a "where in" clause. + */ + protected function whereIn(Builder $query, array $where): string + { + if (! empty($where['values'])) { + return $this->wrap($where['column']) . ' in (' . $this->parameterize($where['values']) . ')'; + } + + return '0 = 1'; + } + + /** + * Compile a "where not in" clause. + */ + protected function whereNotIn(Builder $query, array $where): string + { + if (! empty($where['values'])) { + return $this->wrap($where['column']) . ' not in (' . $this->parameterize($where['values']) . ')'; + } + + return '1 = 1'; + } + + /** + * Compile a "where not in raw" clause. + * + * For safety, whereIntegerInRaw ensures this method is only used with integer values. + */ + protected function whereNotInRaw(Builder $query, array $where): string + { + if (! empty($where['values'])) { + return $this->wrap($where['column']) . ' not in (' . implode(', ', $where['values']) . ')'; + } + + return '1 = 1'; + } + + /** + * Compile a "where in raw" clause. + * + * For safety, whereIntegerInRaw ensures this method is only used with integer values. + */ + protected function whereInRaw(Builder $query, array $where): string + { + if (! empty($where['values'])) { + return $this->wrap($where['column']) . ' in (' . implode(', ', $where['values']) . ')'; + } + + return '0 = 1'; + } + + /** + * Compile a "where null" clause. + */ + protected function whereNull(Builder $query, array $where): string + { + return $this->wrap($where['column']) . ' is null'; + } + + /** + * Compile a "where not null" clause. + */ + protected function whereNotNull(Builder $query, array $where): string + { + return $this->wrap($where['column']) . ' is not null'; + } + + /** + * Compile a "between" where clause. + */ + protected function whereBetween(Builder $query, array $where): string + { + $between = $where['not'] ? 'not between' : 'between'; + + $min = $this->parameter(is_array($where['values']) ? Arr::first($where['values']) : $where['values'][0]); + + $max = $this->parameter(is_array($where['values']) ? Arr::last($where['values']) : $where['values'][1]); + + return $this->wrap($where['column']) . ' ' . $between . ' ' . $min . ' and ' . $max; + } + + /** + * Compile a "between" where clause. + */ + protected function whereBetweenColumns(Builder $query, array $where): string + { + $between = $where['not'] ? 'not between' : 'between'; + + $min = $this->wrap(is_array($where['values']) ? Arr::first($where['values']) : $where['values'][0]); + + $max = $this->wrap(is_array($where['values']) ? Arr::last($where['values']) : $where['values'][1]); + + return $this->wrap($where['column']) . ' ' . $between . ' ' . $min . ' and ' . $max; + } + + /** + * Compile a "value between" where clause. + */ + protected function whereValueBetween(Builder $query, array $where): string + { + $between = $where['not'] ? 'not between' : 'between'; + + $min = $this->wrap(is_array($where['columns']) ? Arr::first($where['columns']) : $where['columns'][0]); + + $max = $this->wrap(is_array($where['columns']) ? Arr::last($where['columns']) : $where['columns'][1]); + + return $this->parameter($where['value']) . ' ' . $between . ' ' . $min . ' and ' . $max; + } + + /** + * Compile a "where date" clause. + */ + protected function whereDate(Builder $query, array $where): string + { + return $this->dateBasedWhere('date', $query, $where); + } + + /** + * Compile a "where time" clause. + */ + protected function whereTime(Builder $query, array $where): string + { + return $this->dateBasedWhere('time', $query, $where); + } + + /** + * Compile a "where day" clause. + */ + protected function whereDay(Builder $query, array $where): string + { + return $this->dateBasedWhere('day', $query, $where); + } + + /** + * Compile a "where month" clause. + */ + protected function whereMonth(Builder $query, array $where): string + { + return $this->dateBasedWhere('month', $query, $where); + } + + /** + * Compile a "where year" clause. + */ + protected function whereYear(Builder $query, array $where): string + { + return $this->dateBasedWhere('year', $query, $where); + } + + /** + * Compile a date based where clause. + */ + protected function dateBasedWhere(string $type, Builder $query, array $where): string + { + $value = $this->parameter($where['value']); + + return $type . '(' . $this->wrap($where['column']) . ') ' . $where['operator'] . ' ' . $value; + } + + /** + * Compile a where clause comparing two columns. + */ + protected function whereColumn(Builder $query, array $where): string + { + return $this->wrap($where['first']) . ' ' . $where['operator'] . ' ' . $this->wrap($where['second']); + } + + /** + * Compile a nested where clause. + */ + protected function whereNested(Builder $query, array $where): string + { + // Here we will calculate what portion of the string we need to remove. If this + // is a join clause query, we need to remove the "on" portion of the SQL and + // if it is a normal query we need to take the leading "where" of queries. + $offset = $where['query'] instanceof JoinClause ? 3 : 6; + + return '(' . substr($this->compileWheres($where['query']), $offset) . ')'; + } + + /** + * Compile a where condition with a sub-select. + */ + protected function whereSub(Builder $query, array $where): string + { + $select = $this->compileSelect($where['query']); + + return $this->wrap($where['column']) . ' ' . $where['operator'] . " ({$select})"; + } + + /** + * Compile a where exists clause. + */ + protected function whereExists(Builder $query, array $where): string + { + return 'exists (' . $this->compileSelect($where['query']) . ')'; + } + + /** + * Compile a where not exists clause. + */ + protected function whereNotExists(Builder $query, array $where): string + { + return 'not exists (' . $this->compileSelect($where['query']) . ')'; + } + + /** + * Compile a where row values condition. + */ + protected function whereRowValues(Builder $query, array $where): string + { + $columns = $this->columnize($where['columns']); + + $values = $this->parameterize($where['values']); + + return '(' . $columns . ') ' . $where['operator'] . ' (' . $values . ')'; + } + + /** + * Compile a "where JSON boolean" clause. + */ + protected function whereJsonBoolean(Builder $query, array $where): string + { + $column = $this->wrapJsonBooleanSelector($where['column']); + + $value = $this->wrapJsonBooleanValue( + $this->parameter($where['value']) + ); + + return $column . ' ' . $where['operator'] . ' ' . $value; + } + + /** + * Compile a "where JSON contains" clause. + */ + protected function whereJsonContains(Builder $query, array $where): string + { + $not = $where['not'] ? 'not ' : ''; + + return $not . $this->compileJsonContains( + $where['column'], + $this->parameter($where['value']) + ); + } + + /** + * Compile a "JSON contains" statement into SQL. + * + * @throws RuntimeException + */ + protected function compileJsonContains(string $column, string $value): string + { + throw new RuntimeException('This database engine does not support JSON contains operations.'); + } + + /** + * Compile a "where JSON overlaps" clause. + */ + protected function whereJsonOverlaps(Builder $query, array $where): string + { + $not = $where['not'] ? 'not ' : ''; + + return $not . $this->compileJsonOverlaps( + $where['column'], + $this->parameter($where['value']) + ); + } + + /** + * Compile a "JSON overlaps" statement into SQL. + * + * @throws RuntimeException + */ + protected function compileJsonOverlaps(string $column, string $value): string + { + throw new RuntimeException('This database engine does not support JSON overlaps operations.'); + } + + /** + * Prepare the binding for a "JSON contains" statement. + */ + public function prepareBindingForJsonContains(mixed $binding): mixed + { + return json_encode($binding, JSON_UNESCAPED_UNICODE); + } + + /** + * Compile a "where JSON contains key" clause. + */ + protected function whereJsonContainsKey(Builder $query, array $where): string + { + $not = $where['not'] ? 'not ' : ''; + + return $not . $this->compileJsonContainsKey( + $where['column'] + ); + } + + /** + * Compile a "JSON contains key" statement into SQL. + * + * @throws RuntimeException + */ + protected function compileJsonContainsKey(string $column): string + { + throw new RuntimeException('This database engine does not support JSON contains key operations.'); + } + + /** + * Compile a "where JSON length" clause. + */ + protected function whereJsonLength(Builder $query, array $where): string + { + return $this->compileJsonLength( + $where['column'], + $where['operator'], + $this->parameter($where['value']) + ); + } + + /** + * Compile a "JSON length" statement into SQL. + * + * @throws RuntimeException + */ + protected function compileJsonLength(string $column, string $operator, string $value): string + { + throw new RuntimeException('This database engine does not support JSON length operations.'); + } + + /** + * Compile a "JSON value cast" statement into SQL. + */ + public function compileJsonValueCast(string $value): string + { + return $value; + } + + /** + * Compile a "where fulltext" clause. + */ + public function whereFullText(Builder $query, array $where): string + { + throw new RuntimeException('This database engine does not support fulltext search operations.'); + } + + /** + * Compile a clause based on an expression. + */ + public function whereExpression(Builder $query, array $where): string + { + return $where['column']->getValue($this); + } + + /** + * Compile the "group by" portions of the query. + */ + protected function compileGroups(Builder $query, array $groups): string + { + return 'group by ' . $this->columnize($groups); + } + + /** + * Compile the "having" portions of the query. + */ + protected function compileHavings(Builder $query): string + { + return 'having ' . $this->removeLeadingBoolean((new Collection($query->havings))->map(function ($having) { + return $having['boolean'] . ' ' . $this->compileHaving($having); + })->implode(' ')); + } + + /** + * Compile a single having clause. + */ + protected function compileHaving(array $having): string + { + // If the having clause is "raw", we can just return the clause straight away + // without doing any more processing on it. Otherwise, we will compile the + // clause into SQL based on the components that make it up from builder. + return match ($having['type']) { + 'Raw' => $having['sql'], + 'between' => $this->compileHavingBetween($having), + 'Null' => $this->compileHavingNull($having), + 'NotNull' => $this->compileHavingNotNull($having), + 'bit' => $this->compileHavingBit($having), + 'Expression' => $this->compileHavingExpression($having), + 'Nested' => $this->compileNestedHavings($having), + default => $this->compileBasicHaving($having), + }; + } + + /** + * Compile a basic having clause. + */ + protected function compileBasicHaving(array $having): string + { + $column = $this->wrap($having['column']); + + $parameter = $this->parameter($having['value']); + + return $column . ' ' . $having['operator'] . ' ' . $parameter; + } + + /** + * Compile a "between" having clause. + */ + protected function compileHavingBetween(array $having): string + { + $between = $having['not'] ? 'not between' : 'between'; + + $column = $this->wrap($having['column']); + + $min = $this->parameter(head($having['values'])); + + $max = $this->parameter(last($having['values'])); + + return $column . ' ' . $between . ' ' . $min . ' and ' . $max; + } + + /** + * Compile a having null clause. + */ + protected function compileHavingNull(array $having): string + { + $column = $this->wrap($having['column']); + + return $column . ' is null'; + } + + /** + * Compile a having not null clause. + */ + protected function compileHavingNotNull(array $having): string + { + $column = $this->wrap($having['column']); + + return $column . ' is not null'; + } + + /** + * Compile a having clause involving a bit operator. + */ + protected function compileHavingBit(array $having): string + { + $column = $this->wrap($having['column']); + + $parameter = $this->parameter($having['value']); + + return '(' . $column . ' ' . $having['operator'] . ' ' . $parameter . ') != 0'; + } + + /** + * Compile a having clause involving an expression. + */ + protected function compileHavingExpression(array $having): string + { + return $having['column']->getValue($this); + } + + /** + * Compile a nested having clause. + */ + protected function compileNestedHavings(array $having): string + { + return '(' . substr($this->compileHavings($having['query']), 7) . ')'; + } + + /** + * Compile the "order by" portions of the query. + */ + protected function compileOrders(Builder $query, array $orders): string + { + if (! empty($orders)) { + return 'order by ' . implode(', ', $this->compileOrdersToArray($query, $orders)); + } + + return ''; + } + + /** + * Compile the query orders to an array. + */ + protected function compileOrdersToArray(Builder $query, array $orders): array + { + return array_map(function ($order) use ($query) { + if (isset($order['sql']) && $order['sql'] instanceof Expression) { + return $order['sql']->getValue($query->getGrammar()); + } + + return $order['sql'] ?? $this->wrap($order['column']) . ' ' . $order['direction']; + }, $orders); + } + + /** + * Compile the random statement into SQL. + */ + public function compileRandom(string|int $seed): string + { + return 'RANDOM()'; + } + + /** + * Compile the "limit" portions of the query. + */ + protected function compileLimit(Builder $query, int $limit): string + { + return 'limit ' . (int) $limit; + } + + /** + * Compile a group limit clause. + */ + protected function compileGroupLimit(Builder $query): string + { + $selectBindings = array_merge($query->getRawBindings()['select'], $query->getRawBindings()['order']); + + $query->setBindings($selectBindings, 'select'); + $query->setBindings([], 'order'); + + $limit = (int) $query->groupLimit['value']; + $offset = $query->offset; + + if (isset($offset)) { + $offset = (int) $offset; + $limit += $offset; + + $query->offset = null; + } + + $components = $this->compileComponents($query); + + $components['columns'] .= $this->compileRowNumber( + $query->groupLimit['column'], + $components['orders'] ?? '' + ); + + unset($components['orders']); + + $table = $this->wrap('laravel_table'); + $row = $this->wrap('laravel_row'); + + $sql = $this->concatenate($components); + + $sql = 'select * from (' . $sql . ') as ' . $table . ' where ' . $row . ' <= ' . $limit; + + if (isset($offset)) { + $sql .= ' and ' . $row . ' > ' . $offset; + } + + return $sql . ' order by ' . $row; + } + + /** + * Compile a row number clause. + */ + protected function compileRowNumber(string $partition, string $orders): string + { + $over = trim('partition by ' . $this->wrap($partition) . ' ' . $orders); + + return ', row_number() over (' . $over . ') as ' . $this->wrap('laravel_row'); + } + + /** + * Compile the "offset" portions of the query. + */ + protected function compileOffset(Builder $query, int $offset): string + { + return 'offset ' . (int) $offset; + } + + /** + * Compile the "union" queries attached to the main query. + */ + protected function compileUnions(Builder $query): string + { + $sql = ''; + + foreach ($query->unions as $union) { + $sql .= $this->compileUnion($union); + } + + if (! empty($query->unionOrders)) { + $sql .= ' ' . $this->compileOrders($query, $query->unionOrders); + } + + if (isset($query->unionLimit)) { + $sql .= ' ' . $this->compileLimit($query, $query->unionLimit); + } + + if (isset($query->unionOffset)) { + $sql .= ' ' . $this->compileOffset($query, $query->unionOffset); + } + + return ltrim($sql); + } + + /** + * Compile a single union statement. + */ + protected function compileUnion(array $union): string + { + $conjunction = $union['all'] ? ' union all ' : ' union '; + + return $conjunction . $this->wrapUnion($union['query']->toSql()); + } + + /** + * Wrap a union subquery in parentheses. + */ + protected function wrapUnion(string $sql): string + { + return '(' . $sql . ')'; + } + + /** + * Compile a union aggregate query into SQL. + */ + protected function compileUnionAggregate(Builder $query): string + { + $sql = $this->compileAggregate($query, $query->aggregate); + + $query->aggregate = null; + + return $sql . ' from (' . $this->compileSelect($query) . ') as ' . $this->wrapTable('temp_table'); + } + + /** + * Compile an exists statement into SQL. + */ + public function compileExists(Builder $query): string + { + $select = $this->compileSelect($query); + + return "select exists({$select}) as {$this->wrap('exists')}"; + } + + /** + * Compile an insert statement into SQL. + */ + public function compileInsert(Builder $query, array $values): string + { + // Essentially we will force every insert to be treated as a batch insert which + // simply makes creating the SQL easier for us since we can utilize the same + // basic routine regardless of an amount of records given to us to insert. + $table = $this->wrapTable($query->from); + + if (empty($values)) { + return "insert into {$table} default values"; + } + + if (! is_array(Arr::first($values))) { + $values = [$values]; + } + + $columns = $this->columnize(array_keys(Arr::first($values))); + + // We need to build a list of parameter place-holders of values that are bound + // to the query. Each insert should have the exact same number of parameter + // bindings so we will loop through the record and parameterize them all. + $parameters = (new Collection($values)) + ->map(fn ($record) => '(' . $this->parameterize($record) . ')') + ->implode(', '); + + return "insert into {$table} ({$columns}) values {$parameters}"; + } + + /** + * Compile an insert ignore statement into SQL. + * + * @throws RuntimeException + */ + public function compileInsertOrIgnore(Builder $query, array $values): string + { + throw new RuntimeException('This database engine does not support inserting while ignoring errors.'); + } + + /** + * Compile an insert and get ID statement into SQL. + */ + public function compileInsertGetId(Builder $query, array $values, ?string $sequence): string + { + return $this->compileInsert($query, $values); + } + + /** + * Compile an insert statement using a subquery into SQL. + */ + public function compileInsertUsing(Builder $query, array $columns, string $sql): string + { + $table = $this->wrapTable($query->from); + + if (empty($columns) || $columns === ['*']) { + return "insert into {$table} {$sql}"; + } + + return "insert into {$table} ({$this->columnize($columns)}) {$sql}"; + } + + /** + * Compile an insert ignore statement using a subquery into SQL. + * + * @throws RuntimeException + */ + public function compileInsertOrIgnoreUsing(Builder $query, array $columns, string $sql): string + { + throw new RuntimeException('This database engine does not support inserting while ignoring errors.'); + } + + /** + * Compile an update statement into SQL. + */ + public function compileUpdate(Builder $query, array $values): string + { + $table = $this->wrapTable($query->from); + + $columns = $this->compileUpdateColumns($query, $values); + + $where = $this->compileWheres($query); + + return trim( + isset($query->joins) + ? $this->compileUpdateWithJoins($query, $table, $columns, $where) + : $this->compileUpdateWithoutJoins($query, $table, $columns, $where) + ); + } + + /** + * Compile the columns for an update statement. + */ + protected function compileUpdateColumns(Builder $query, array $values): string + { + return (new Collection($values)) + ->map(fn ($value, $key) => $this->wrap($key) . ' = ' . $this->parameter($value)) + ->implode(', '); + } + + /** + * Compile an update statement without joins into SQL. + */ + protected function compileUpdateWithoutJoins(Builder $query, string $table, string $columns, string $where): string + { + return "update {$table} set {$columns} {$where}"; + } + + /** + * Compile an update statement with joins into SQL. + */ + protected function compileUpdateWithJoins(Builder $query, string $table, string $columns, string $where): string + { + $joins = $this->compileJoins($query, $query->joins); + + return "update {$table} {$joins} set {$columns} {$where}"; + } + + /** + * Compile an "upsert" statement into SQL. + * + * @throws RuntimeException + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update): string + { + throw new RuntimeException('This database engine does not support upserts.'); + } + + /** + * Prepare the bindings for an update statement. + */ + public function prepareBindingsForUpdate(array $bindings, array $values): array + { + $cleanBindings = Arr::except($bindings, ['select', 'join']); + + $values = Arr::flatten(array_map(fn ($value) => value($value), $values)); + + return array_values( + array_merge($bindings['join'], $values, Arr::flatten($cleanBindings)) + ); + } + + /** + * Compile a delete statement into SQL. + */ + public function compileDelete(Builder $query): string + { + $table = $this->wrapTable($query->from); + + $where = $this->compileWheres($query); + + return trim( + isset($query->joins) + ? $this->compileDeleteWithJoins($query, $table, $where) + : $this->compileDeleteWithoutJoins($query, $table, $where) + ); + } + + /** + * Compile a delete statement without joins into SQL. + */ + protected function compileDeleteWithoutJoins(Builder $query, string $table, string $where): string + { + return "delete from {$table} {$where}"; + } + + /** + * Compile a delete statement with joins into SQL. + */ + protected function compileDeleteWithJoins(Builder $query, string $table, string $where): string + { + $alias = last(explode(' as ', $table)); + + $joins = $this->compileJoins($query, $query->joins); + + return "delete {$alias} from {$table} {$joins} {$where}"; + } + + /** + * Prepare the bindings for a delete statement. + */ + public function prepareBindingsForDelete(array $bindings): array + { + return Arr::flatten( + Arr::except($bindings, 'select') + ); + } + + /** + * Compile a truncate table statement into SQL. + */ + public function compileTruncate(Builder $query): array + { + return ['truncate table ' . $this->wrapTable($query->from) => []]; + } + + /** + * Compile the lock into SQL. + */ + protected function compileLock(Builder $query, bool|string $value): string + { + return is_string($value) ? $value : ''; + } + + /** + * Compile a query to get the number of open connections for a database. + */ + public function compileThreadCount(): ?string + { + return null; + } + + /** + * Determine if the grammar supports savepoints. + */ + public function supportsSavepoints(): bool + { + return true; + } + + /** + * Compile the SQL statement to define a savepoint. + */ + public function compileSavepoint(string $name): string + { + return 'SAVEPOINT ' . $name; + } + + /** + * Compile the SQL statement to execute a savepoint rollback. + */ + public function compileSavepointRollBack(string $name): string + { + return 'ROLLBACK TO SAVEPOINT ' . $name; + } + + /** + * Wrap the given JSON selector for boolean values. + */ + protected function wrapJsonBooleanSelector(string $value): string + { + return $this->wrapJsonSelector($value); + } + + /** + * Wrap the given JSON boolean value. + */ + protected function wrapJsonBooleanValue(string $value): string + { + return $value; + } + + /** + * Concatenate an array of segments, removing empties. + */ + protected function concatenate(array $segments): string + { + return implode(' ', array_filter($segments, function ($value) { + return (string) $value !== ''; + })); + } + + /** + * Remove the leading boolean from a statement. + */ + protected function removeLeadingBoolean(string $value): string + { + return preg_replace('/and |or /i', '', $value, 1); + } + + /** + * Substitute the given bindings into the given raw SQL query. + */ + public function substituteBindingsIntoRawSql(string $sql, array $bindings): string + { + $bindings = array_map(fn ($value) => $this->escape($value, is_resource($value) || gettype($value) === 'resource (closed)'), $bindings); + + $query = ''; + + $isStringLiteral = false; + + for ($i = 0; $i < strlen($sql); ++$i) { + $char = $sql[$i]; + $nextChar = $sql[$i + 1] ?? null; + + // Single quotes can be escaped as '' according to the SQL standard while + // MySQL uses \'. Postgres has operators like ?| that must get encoded + // in PHP like ??|. We should skip over the escaped characters here. + if (in_array($char . $nextChar, ["\\'", "''", '??'])) { + $query .= $char . $nextChar; + ++$i; + } elseif ($char === "'") { // Starting / leaving string literal... + $query .= $char; + $isStringLiteral = ! $isStringLiteral; + } elseif ($char === '?' && ! $isStringLiteral) { // Substitutable binding... + $query .= array_shift($bindings) ?? '?'; + } else { // Normal character... + $query .= $char; + } + } + + return $query; + } + + /** + * Get the grammar specific operators. + * + * @return string[] + */ + public function getOperators(): array + { + return $this->operators; + } + + /** + * Get the grammar specific bitwise operators. + * + * @return string[] + */ + public function getBitwiseOperators(): array + { + return $this->bitwiseOperators; + } +} diff --git a/src/database/src/Query/Grammars/MariaDbGrammar.php b/src/database/src/Query/Grammars/MariaDbGrammar.php new file mode 100755 index 000000000..cc628c9cc --- /dev/null +++ b/src/database/src/Query/Grammars/MariaDbGrammar.php @@ -0,0 +1,54 @@ +wrapJsonFieldAndPath($value); + + return 'json_value(' . $field . $path . ')'; + } +} diff --git a/src/database/src/Query/Grammars/MySqlGrammar.php b/src/database/src/Query/Grammars/MySqlGrammar.php new file mode 100755 index 000000000..0500c8432 --- /dev/null +++ b/src/database/src/Query/Grammars/MySqlGrammar.php @@ -0,0 +1,421 @@ +whereBasic($query, $where); + } + + /** + * Add a "where null" clause to the query. + */ + protected function whereNull(Builder $query, array $where): string + { + $columnValue = (string) $this->getValue($where['column']); + + if ($this->isJsonSelector($columnValue)) { + [$field, $path] = $this->wrapJsonFieldAndPath($columnValue); + + return '(json_extract(' . $field . $path . ') is null OR json_type(json_extract(' . $field . $path . ')) = \'NULL\')'; + } + + return parent::whereNull($query, $where); + } + + /** + * Add a "where not null" clause to the query. + */ + protected function whereNotNull(Builder $query, array $where): string + { + $columnValue = (string) $this->getValue($where['column']); + + if ($this->isJsonSelector($columnValue)) { + [$field, $path] = $this->wrapJsonFieldAndPath($columnValue); + + return '(json_extract(' . $field . $path . ') is not null AND json_type(json_extract(' . $field . $path . ')) != \'NULL\')'; + } + + return parent::whereNotNull($query, $where); + } + + /** + * Compile a "where fulltext" clause. + */ + public function whereFullText(Builder $query, array $where): string + { + $columns = $this->columnize($where['columns']); + + $value = $this->parameter($where['value']); + + $mode = ($where['options']['mode'] ?? []) === 'boolean' + ? ' in boolean mode' + : ' in natural language mode'; + + $expanded = ($where['options']['expanded'] ?? []) && ($where['options']['mode'] ?? []) !== 'boolean' + ? ' with query expansion' + : ''; + + return "match ({$columns}) against (" . $value . "{$mode}{$expanded})"; + } + + /** + * Compile the index hints for the query. + */ + protected function compileIndexHint(Builder $query, IndexHint $indexHint): string + { + return match ($indexHint->type) { + 'hint' => "use index ({$indexHint->index})", + 'force' => "force index ({$indexHint->index})", + default => "ignore index ({$indexHint->index})", + }; + } + + /** + * Compile a group limit clause. + */ + protected function compileGroupLimit(Builder $query): string + { + return $this->useLegacyGroupLimit($query) + ? $this->compileLegacyGroupLimit($query) + : parent::compileGroupLimit($query); + } + + /** + * Determine whether to use a legacy group limit clause for MySQL < 8.0. + */ + public function useLegacyGroupLimit(Builder $query): bool + { + $version = $query->getConnection()->getServerVersion(); + + // @phpstan-ignore method.notFound (MySqlGrammar is only used with MySqlConnection which has isMaria()) + return ! $query->getConnection()->isMaria() && version_compare($version, '8.0.11', '<'); + } + + /** + * Compile a group limit clause for MySQL < 8.0. + * + * Derived from https://softonsofa.com/tweaking-eloquent-relations-how-to-get-n-related-models-per-parent/. + */ + protected function compileLegacyGroupLimit(Builder $query): string + { + $limit = (int) $query->groupLimit['value']; + $offset = $query->offset; + + if (isset($offset)) { + $offset = (int) $offset; + $limit += $offset; + + $query->offset = null; + } + + $column = last(explode('.', $query->groupLimit['column'])); + $column = $this->wrap($column); + + $partition = ', @laravel_row := if(@laravel_group = ' . $column . ', @laravel_row + 1, 1) as `laravel_row`'; + $partition .= ', @laravel_group := ' . $column; + + $orders = (array) $query->orders; + + array_unshift($orders, [ + 'column' => $query->groupLimit['column'], + 'direction' => 'asc', + ]); + + $query->orders = $orders; + + $components = $this->compileComponents($query); + + $sql = $this->concatenate($components); + + $from = '(select @laravel_row := 0, @laravel_group := 0) as `laravel_vars`, (' . $sql . ') as `laravel_table`'; + + $sql = 'select `laravel_table`.*' . $partition . ' from ' . $from . ' having `laravel_row` <= ' . $limit; + + if (isset($offset)) { + $sql .= ' and `laravel_row` > ' . $offset; + } + + return $sql . ' order by `laravel_row`'; + } + + /** + * Compile an insert ignore statement into SQL. + */ + public function compileInsertOrIgnore(Builder $query, array $values): string + { + return Str::replaceFirst('insert', 'insert ignore', $this->compileInsert($query, $values)); + } + + /** + * Compile an insert ignore statement using a subquery into SQL. + */ + public function compileInsertOrIgnoreUsing(Builder $query, array $columns, string $sql): string + { + return Str::replaceFirst('insert', 'insert ignore', $this->compileInsertUsing($query, $columns, $sql)); + } + + /** + * Compile a "JSON contains" statement into SQL. + */ + protected function compileJsonContains(string $column, string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'json_contains(' . $field . ', ' . $value . $path . ')'; + } + + /** + * Compile a "JSON overlaps" statement into SQL. + */ + protected function compileJsonOverlaps(string $column, string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'json_overlaps(' . $field . ', ' . $value . $path . ')'; + } + + /** + * Compile a "JSON contains key" statement into SQL. + */ + protected function compileJsonContainsKey(string $column): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'ifnull(json_contains_path(' . $field . ', \'one\'' . $path . '), 0)'; + } + + /** + * Compile a "JSON length" statement into SQL. + */ + protected function compileJsonLength(string $column, string $operator, string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'json_length(' . $field . $path . ') ' . $operator . ' ' . $value; + } + + /** + * Compile a "JSON value cast" statement into SQL. + */ + public function compileJsonValueCast(string $value): string + { + return 'cast(' . $value . ' as json)'; + } + + /** + * Compile the random statement into SQL. + */ + public function compileRandom(string|int $seed): string + { + return 'RAND(' . $seed . ')'; + } + + /** + * Compile the lock into SQL. + */ + protected function compileLock(Builder $query, bool|string $value): string + { + if (! is_string($value)) { + return $value ? 'for update' : 'lock in share mode'; + } + + return $value; + } + + /** + * Compile an insert statement into SQL. + */ + public function compileInsert(Builder $query, array $values): string + { + if (empty($values)) { + $values = [[]]; + } + + return parent::compileInsert($query, $values); + } + + /** + * Compile the columns for an update statement. + */ + protected function compileUpdateColumns(Builder $query, array $values): string + { + return (new Collection($values))->map(function ($value, $key) { + if ($this->isJsonSelector($key)) { + return $this->compileJsonUpdateColumn($key, $value); + } + + return $this->wrap($key) . ' = ' . $this->parameter($value); + })->implode(', '); + } + + /** + * Compile an "upsert" statement into SQL. + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update): string + { + $useUpsertAlias = $query->connection->getConfig('use_upsert_alias'); + + $sql = $this->compileInsert($query, $values); + + if ($useUpsertAlias) { + $sql .= ' as laravel_upsert_alias'; + } + + $sql .= ' on duplicate key update '; + + $columns = (new Collection($update))->map(function ($value, $key) use ($useUpsertAlias) { + if (! is_numeric($key)) { + return $this->wrap($key) . ' = ' . $this->parameter($value); + } + + return $useUpsertAlias + ? $this->wrap($value) . ' = ' . $this->wrap('laravel_upsert_alias') . '.' . $this->wrap($value) + : $this->wrap($value) . ' = values(' . $this->wrap($value) . ')'; + })->implode(', '); + + return $sql . $columns; + } + + /** + * Compile a "lateral join" clause. + */ + public function compileJoinLateral(JoinLateralClause $join, string $expression): string + { + return trim("{$join->type} join lateral {$expression} on true"); + } + + /** + * Prepare a JSON column being updated using the JSON_SET function. + */ + protected function compileJsonUpdateColumn(string $key, mixed $value): string + { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } elseif (is_array($value)) { + $value = 'cast(? as json)'; + } else { + $value = $this->parameter($value); + } + + [$field, $path] = $this->wrapJsonFieldAndPath($key); + + return "{$field} = json_set({$field}{$path}, {$value})"; + } + + /** + * Compile an update statement without joins into SQL. + */ + protected function compileUpdateWithoutJoins(Builder $query, string $table, string $columns, string $where): string + { + $sql = parent::compileUpdateWithoutJoins($query, $table, $columns, $where); + + if (! empty($query->orders)) { + $sql .= ' ' . $this->compileOrders($query, $query->orders); + } + + if (isset($query->limit)) { + $sql .= ' ' . $this->compileLimit($query, $query->limit); + } + + return $sql; + } + + /** + * Prepare the bindings for an update statement. + * + * Booleans, integers, and doubles are inserted into JSON updates as raw values. + */ + #[Override] + public function prepareBindingsForUpdate(array $bindings, array $values): array + { + $values = (new Collection($values)) + ->reject(fn ($value, $column) => $this->isJsonSelector($column) && is_bool($value)) + ->map(fn ($value) => is_array($value) ? json_encode($value) : $value) + ->all(); + + return parent::prepareBindingsForUpdate($bindings, $values); + } + + /** + * Compile a delete query that does not use joins. + */ + protected function compileDeleteWithoutJoins(Builder $query, string $table, string $where): string + { + $sql = parent::compileDeleteWithoutJoins($query, $table, $where); + + // When using MySQL, delete statements may contain order by statements and limits + // so we will compile both of those here. Once we have finished compiling this + // we will return the completed SQL statement so it will be executed for us. + if (! empty($query->orders)) { + $sql .= ' ' . $this->compileOrders($query, $query->orders); + } + + if (isset($query->limit)) { + $sql .= ' ' . $this->compileLimit($query, $query->limit); + } + + return $sql; + } + + /** + * Compile a query to get the number of open connections for a database. + */ + public function compileThreadCount(): string + { + return 'select variable_value as `Value` from performance_schema.session_status where variable_name = \'threads_connected\''; + } + + /** + * Wrap a single string in keyword identifiers. + */ + protected function wrapValue(string $value): string + { + return $value === '*' ? $value : '`' . str_replace('`', '``', $value) . '`'; + } + + /** + * Wrap the given JSON selector. + */ + protected function wrapJsonSelector(string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_unquote(json_extract(' . $field . $path . '))'; + } + + /** + * Wrap the given JSON selector for boolean values. + */ + protected function wrapJsonBooleanSelector(string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_extract(' . $field . $path . ')'; + } +} diff --git a/src/database/src/Query/Grammars/PostgresGrammar.php b/src/database/src/Query/Grammars/PostgresGrammar.php new file mode 100755 index 000000000..9c4b0f8c1 --- /dev/null +++ b/src/database/src/Query/Grammars/PostgresGrammar.php @@ -0,0 +1,709 @@ +', '<=', '>=', '<>', '!=', + 'like', 'not like', 'between', 'ilike', 'not ilike', + '~', '&', '|', '#', '<<', '>>', '<<=', '>>=', + '&&', '@>', '<@', '?', '?|', '?&', '||', '-', '@?', '@@', '#-', + 'is distinct from', 'is not distinct from', + ]; + + /** + * The Postgres grammar specific custom operators. + * + * @var string[] + */ + protected static array $customOperators = []; + + /** + * The grammar specific bitwise operators. + * + * @var string[] + */ + protected array $bitwiseOperators = [ + '~', '&', '|', '#', '<<', '>>', '<<=', '>>=', + ]; + + /** + * Indicates if the cascade option should be used when truncating. + */ + protected static bool $cascadeTruncate = true; + + /** + * Compile a basic where clause. + */ + protected function whereBasic(Builder $query, array $where): string + { + if (str_contains(strtolower($where['operator']), 'like')) { + return sprintf( + '%s::text %s %s', + $this->wrap($where['column']), + $where['operator'], + $this->parameter($where['value']) + ); + } + + return parent::whereBasic($query, $where); + } + + /** + * Compile a bitwise operator where clause. + */ + protected function whereBitwise(Builder $query, array $where): string + { + $value = $this->parameter($where['value']); + + $operator = str_replace('?', '??', $where['operator']); + + return '(' . $this->wrap($where['column']) . ' ' . $operator . ' ' . $value . ')::bool'; + } + + /** + * Compile a "where like" clause. + */ + protected function whereLike(Builder $query, array $where): string + { + $where['operator'] = $where['not'] ? 'not ' : ''; + + $where['operator'] .= $where['caseSensitive'] ? 'like' : 'ilike'; + + return $this->whereBasic($query, $where); + } + + /** + * Compile a "where date" clause. + */ + protected function whereDate(Builder $query, array $where): string + { + $column = $this->wrap($where['column']); + $value = $this->parameter($where['value']); + + if ($this->isJsonSelector($where['column'])) { + $column = '(' . $column . ')'; + } + + return $column . '::date ' . $where['operator'] . ' ' . $value; + } + + /** + * Compile a "where time" clause. + */ + protected function whereTime(Builder $query, array $where): string + { + $column = $this->wrap($where['column']); + $value = $this->parameter($where['value']); + + if ($this->isJsonSelector($where['column'])) { + $column = '(' . $column . ')'; + } + + return $column . '::time ' . $where['operator'] . ' ' . $value; + } + + /** + * Compile a date based where clause. + */ + protected function dateBasedWhere(string $type, Builder $query, array $where): string + { + $value = $this->parameter($where['value']); + + return 'extract(' . $type . ' from ' . $this->wrap($where['column']) . ') ' . $where['operator'] . ' ' . $value; + } + + /** + * Compile a "where fulltext" clause. + */ + public function whereFullText(Builder $query, array $where): string + { + $language = $where['options']['language'] ?? 'english'; + + if (! in_array($language, $this->validFullTextLanguages())) { + $language = 'english'; + } + + $columns = (new Collection($where['columns'])) + ->map(fn ($column) => "to_tsvector('{$language}', {$this->wrap($column)})") + ->implode(' || '); + + $mode = 'plainto_tsquery'; + + if (($where['options']['mode'] ?? []) === 'phrase') { + $mode = 'phraseto_tsquery'; + } + + if (($where['options']['mode'] ?? []) === 'websearch') { + $mode = 'websearch_to_tsquery'; + } + + if (($where['options']['mode'] ?? []) === 'raw') { + $mode = 'to_tsquery'; + } + + return "({$columns}) @@ {$mode}('{$language}', {$this->parameter($where['value'])})"; + } + + /** + * Get an array of valid full text languages. + * + * @return string[] + */ + protected function validFullTextLanguages(): array + { + return [ + 'simple', + 'arabic', + 'danish', + 'dutch', + 'english', + 'finnish', + 'french', + 'german', + 'hungarian', + 'indonesian', + 'irish', + 'italian', + 'lithuanian', + 'nepali', + 'norwegian', + 'portuguese', + 'romanian', + 'russian', + 'spanish', + 'swedish', + 'tamil', + 'turkish', + ]; + } + + /** + * Compile the "select *" portion of the query. + */ + protected function compileColumns(Builder $query, array $columns): ?string + { + // If the query is actually performing an aggregating select, we will let that + // compiler handle the building of the select clauses, as it will need some + // more syntax that is best handled by that function to keep things neat. + if (! is_null($query->aggregate)) { + return null; + } + + if (is_array($query->distinct)) { + $select = 'select distinct on (' . $this->columnize($query->distinct) . ') '; + } elseif ($query->distinct) { + $select = 'select distinct '; + } else { + $select = 'select '; + } + + return $select . $this->columnize($columns); + } + + /** + * Compile a "JSON contains" statement into SQL. + */ + protected function compileJsonContains(string $column, string $value): string + { + $column = str_replace('->>', '->', $this->wrap($column)); + + return '(' . $column . ')::jsonb @> ' . $value; + } + + /** + * Compile a "JSON contains key" statement into SQL. + */ + protected function compileJsonContainsKey(string $column): string + { + $segments = explode('->', $column); + + $lastSegment = array_pop($segments); + + if (filter_var($lastSegment, FILTER_VALIDATE_INT) !== false) { + $i = (int) $lastSegment; + } elseif (preg_match('/\[(-?[0-9]+)\]$/', $lastSegment, $matches)) { + $segments[] = Str::beforeLast($lastSegment, $matches[0]); + + $i = (int) $matches[1]; + } + + $column = str_replace('->>', '->', $this->wrap(implode('->', $segments))); + + if (isset($i)) { + return vsprintf('case when %s then %s else false end', [ + 'jsonb_typeof((' . $column . ")::jsonb) = 'array'", + 'jsonb_array_length((' . $column . ')::jsonb) >= ' . ($i < 0 ? abs($i) : $i + 1), + ]); + } + + $key = "'" . str_replace("'", "''", $lastSegment) . "'"; + + return 'coalesce((' . $column . ')::jsonb ?? ' . $key . ', false)'; + } + + /** + * Compile a "JSON length" statement into SQL. + */ + protected function compileJsonLength(string $column, string $operator, string $value): string + { + $column = str_replace('->>', '->', $this->wrap($column)); + + return 'jsonb_array_length((' . $column . ')::jsonb) ' . $operator . ' ' . $value; + } + + /** + * Compile a single having clause. + */ + protected function compileHaving(array $having): string + { + if ($having['type'] === 'Bitwise') { + return $this->compileHavingBitwise($having); + } + + return parent::compileHaving($having); + } + + /** + * Compile a having clause involving a bitwise operator. + */ + protected function compileHavingBitwise(array $having): string + { + $column = $this->wrap($having['column']); + + $parameter = $this->parameter($having['value']); + + return '(' . $column . ' ' . $having['operator'] . ' ' . $parameter . ')::bool'; + } + + /** + * Compile the lock into SQL. + */ + protected function compileLock(Builder $query, bool|string $value): string + { + if (! is_string($value)) { + return $value ? 'for update' : 'for share'; + } + + return $value; + } + + /** + * Compile an insert ignore statement into SQL. + */ + public function compileInsertOrIgnore(Builder $query, array $values): string + { + return $this->compileInsert($query, $values) . ' on conflict do nothing'; + } + + /** + * Compile an insert ignore statement using a subquery into SQL. + */ + public function compileInsertOrIgnoreUsing(Builder $query, array $columns, string $sql): string + { + return $this->compileInsertUsing($query, $columns, $sql) . ' on conflict do nothing'; + } + + /** + * Compile an insert and get ID statement into SQL. + */ + public function compileInsertGetId(Builder $query, array $values, ?string $sequence): string + { + return $this->compileInsert($query, $values) . ' returning ' . $this->wrap($sequence ?: 'id'); + } + + /** + * Compile an update statement into SQL. + */ + public function compileUpdate(Builder $query, array $values): string + { + if (isset($query->joins) || isset($query->limit)) { + return $this->compileUpdateWithJoinsOrLimit($query, $values); + } + + return parent::compileUpdate($query, $values); + } + + /** + * Compile the columns for an update statement. + */ + protected function compileUpdateColumns(Builder $query, array $values): string + { + return (new Collection($values))->map(function ($value, $key) { + $column = last(explode('.', $key)); + + if ($this->isJsonSelector($key)) { + return $this->compileJsonUpdateColumn($column, $value); + } + + return $this->wrap($column) . ' = ' . $this->parameter($value); + })->implode(', '); + } + + /** + * Compile an "upsert" statement into SQL. + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update): string + { + $sql = $this->compileInsert($query, $values); + + $sql .= ' on conflict (' . $this->columnize($uniqueBy) . ') do update set '; + + $columns = (new Collection($update))->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value) . ' = ' . $this->wrapValue('excluded') . '.' . $this->wrap($value) + : $this->wrap($key) . ' = ' . $this->parameter($value); + })->implode(', '); + + return $sql . $columns; + } + + /** + * Compile a "lateral join" clause. + */ + public function compileJoinLateral(JoinLateralClause $join, string $expression): string + { + return trim("{$join->type} join lateral {$expression} on true"); + } + + /** + * Prepares a JSON column being updated using the JSONB_SET function. + */ + protected function compileJsonUpdateColumn(string $key, mixed $value): string + { + $segments = explode('->', $key); + + $field = $this->wrap(array_shift($segments)); + + $path = "'{" . implode(',', $this->wrapJsonPathAttributes($segments, '"')) . "}'"; + + return "{$field} = jsonb_set({$field}::jsonb, {$path}, {$this->parameter($value)})"; + } + + /** + * Compile an update from statement into SQL. + */ + public function compileUpdateFrom(Builder $query, array $values): string + { + $table = $this->wrapTable($query->from); + + // Each one of the columns in the update statements needs to be wrapped in the + // keyword identifiers, also a place-holder needs to be created for each of + // the values in the list of bindings so we can make the sets statements. + $columns = $this->compileUpdateColumns($query, $values); + + $from = ''; + + if (isset($query->joins)) { + // When using Postgres, updates with joins list the joined tables in the from + // clause, which is different than other systems like MySQL. Here, we will + // compile out the tables that are joined and add them to a from clause. + $froms = (new Collection($query->joins)) + ->map(fn ($join) => $this->wrapTable($join->table)) + ->all(); + + if (count($froms) > 0) { + $from = ' from ' . implode(', ', $froms); + } + } + + $where = $this->compileUpdateWheres($query); + + return trim("update {$table} set {$columns}{$from} {$where}"); + } + + /** + * Compile the additional where clauses for updates with joins. + */ + protected function compileUpdateWheres(Builder $query): string + { + $baseWheres = $this->compileWheres($query); + + if (! isset($query->joins)) { + return $baseWheres; + } + + // Once we compile the join constraints, we will either use them as the where + // clause or append them to the existing base where clauses. If we need to + // strip the leading boolean we will do so when using as the only where. + $joinWheres = $this->compileUpdateJoinWheres($query); + + if (trim($baseWheres) == '') { + return 'where ' . $this->removeLeadingBoolean($joinWheres); + } + + return $baseWheres . ' ' . $joinWheres; + } + + /** + * Compile the "join" clause where clauses for an update. + */ + protected function compileUpdateJoinWheres(Builder $query): string + { + $joinWheres = []; + + // Here we will just loop through all of the join constraints and compile them + // all out then implode them. This should give us "where" like syntax after + // everything has been built and then we will join it to the real wheres. + foreach ($query->joins as $join) { + foreach ($join->wheres as $where) { + $method = "where{$where['type']}"; + + $joinWheres[] = $where['boolean'] . ' ' . $this->{$method}($query, $where); + } + } + + return implode(' ', $joinWheres); + } + + /** + * Prepare the bindings for an update statement. + */ + public function prepareBindingsForUpdateFrom(array $bindings, array $values): array + { + $values = (new Collection($values)) + ->map(function ($value, $column) { + return is_array($value) || ($this->isJsonSelector($column) && ! $this->isExpression($value)) + ? json_encode($value) + : $value; + }) + ->all(); + + $bindingsWithoutWhere = Arr::except($bindings, ['select', 'where']); + + return array_values( + array_merge($values, $bindings['where'], Arr::flatten($bindingsWithoutWhere)) + ); + } + + /** + * Compile an update statement with joins or limit into SQL. + */ + protected function compileUpdateWithJoinsOrLimit(Builder $query, array $values): string + { + $table = $this->wrapTable($query->from); + + $columns = $this->compileUpdateColumns($query, $values); + + $alias = last(preg_split('/\s+as\s+/i', $query->from)); + + $selectSql = $this->compileSelect($query->select($alias . '.ctid')); + + return "update {$table} set {$columns} where {$this->wrap('ctid')} in ({$selectSql})"; + } + + /** + * Prepare the bindings for an update statement. + */ + #[Override] + public function prepareBindingsForUpdate(array $bindings, array $values): array + { + $values = (new Collection($values))->map(function ($value, $column) { + return is_array($value) || ($this->isJsonSelector($column) && ! $this->isExpression($value)) + ? json_encode($value) + : $value; + })->all(); + + $cleanBindings = Arr::except($bindings, 'select'); + + $values = Arr::flatten(array_map(fn ($value) => value($value), $values)); + + return array_values( + array_merge($values, Arr::flatten($cleanBindings)) + ); + } + + /** + * Compile a delete statement into SQL. + */ + public function compileDelete(Builder $query): string + { + if (isset($query->joins) || isset($query->limit)) { + return $this->compileDeleteWithJoinsOrLimit($query); + } + + return parent::compileDelete($query); + } + + /** + * Compile a delete statement with joins or limit into SQL. + */ + protected function compileDeleteWithJoinsOrLimit(Builder $query): string + { + $table = $this->wrapTable($query->from); + + $alias = last(preg_split('/\s+as\s+/i', $query->from)); + + $selectSql = $this->compileSelect($query->select($alias . '.ctid')); + + return "delete from {$table} where {$this->wrap('ctid')} in ({$selectSql})"; + } + + /** + * Compile a truncate table statement into SQL. + */ + public function compileTruncate(Builder $query): array + { + return ['truncate ' . $this->wrapTable($query->from) . ' restart identity' . (static::$cascadeTruncate ? ' cascade' : '') => []]; + } + + /** + * Compile a query to get the number of open connections for a database. + */ + public function compileThreadCount(): ?string + { + return 'select count(*) as "Value" from pg_stat_activity'; + } + + /** + * Wrap the given JSON selector. + */ + protected function wrapJsonSelector(string $value): string + { + $path = explode('->', $value); + + $field = $this->wrapSegments(explode('.', array_shift($path))); + + $wrappedPath = $this->wrapJsonPathAttributes($path); + + $attribute = array_pop($wrappedPath); + + if (! empty($wrappedPath)) { + return $field . '->' . implode('->', $wrappedPath) . '->>' . $attribute; + } + + return $field . '->>' . $attribute; + } + + /** + * Wrap the given JSON selector for boolean values. + */ + protected function wrapJsonBooleanSelector(string $value): string + { + $selector = str_replace( + '->>', + '->', + $this->wrapJsonSelector($value) + ); + + return '(' . $selector . ')::jsonb'; + } + + /** + * Wrap the given JSON boolean value. + */ + protected function wrapJsonBooleanValue(string $value): string + { + return "'" . $value . "'::jsonb"; + } + + /** + * Wrap the attributes of the given JSON path. + */ + protected function wrapJsonPathAttributes(array $path): array + { + $quote = func_num_args() === 2 ? func_get_arg(1) : "'"; + + return (new Collection($path)) + ->map(fn ($attribute) => $this->parseJsonPathArrayKeys($attribute)) + ->collapse() + ->map(function ($attribute) use ($quote) { + // @phpstan-ignore notIdentical.alwaysFalse (PHPDoc type inference too narrow; runtime values can be numeric strings) + return filter_var($attribute, FILTER_VALIDATE_INT) !== false + ? $attribute + : $quote . $attribute . $quote; + }) + ->all(); + } + + /** + * Parse the given JSON path attribute for array keys. + */ + protected function parseJsonPathArrayKeys(string $attribute): array + { + if (preg_match('/(\[[^\]]+\])+$/', $attribute, $parts)) { + $key = Str::beforeLast($attribute, $parts[0]); + + preg_match_all('/\[([^\]]+)\]/', $parts[0], $keys); + + return (new Collection([$key])) + ->merge($keys[1]) + ->diff(['']) + ->values() + ->all(); + } + + return [$attribute]; + } + + /** + * Substitute the given bindings into the given raw SQL query. + */ + public function substituteBindingsIntoRawSql(string $sql, array $bindings): string + { + $query = parent::substituteBindingsIntoRawSql($sql, $bindings); + + foreach ($this->operators as $operator) { + if (! str_contains($operator, '?')) { + continue; + } + + $query = str_replace(str_replace('?', '??', $operator), $operator, $query); + } + + return $query; + } + + /** + * Get the Postgres grammar specific operators. + * + * @return string[] + */ + public function getOperators(): array + { + return array_values(array_unique(array_merge(parent::getOperators(), static::$customOperators))); + } + + /** + * Set any Postgres grammar specific custom operators. + * + * @param string[] $operators + */ + public static function customOperators(array $operators): void + { + static::$customOperators = array_values( + array_merge(static::$customOperators, array_filter(array_filter($operators, 'is_string'))) + ); + } + + /** + * Enable or disable the "cascade" option when compiling the truncate statement. + */ + public static function cascadeOnTruncate(bool $value = true): void + { + static::$cascadeTruncate = $value; + } + + /** + * @deprecated use cascadeOnTruncate + */ + public static function cascadeOnTrucate(bool $value = true): void + { + self::cascadeOnTruncate($value); + } +} diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php new file mode 100755 index 000000000..3f4c7f965 --- /dev/null +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -0,0 +1,376 @@ +', '<=', '>=', '<>', '!=', + 'like', 'not like', 'ilike', + '&', '|', '<<', '>>', + ]; + + /** + * Compile the lock into SQL. + */ + protected function compileLock(Builder $query, bool|string $value): string + { + return ''; + } + + /** + * Wrap a union subquery in parentheses. + */ + protected function wrapUnion(string $sql): string + { + return 'select * from (' . $sql . ')'; + } + + /** + * Compile a basic where clause. + */ + protected function whereBasic(Builder $query, array $where): string + { + if ($where['operator'] === '<=>') { + $column = $this->wrap($where['column']); + $value = $this->parameter($where['value']); + + return "{$column} IS {$value}"; + } + + return parent::whereBasic($query, $where); + } + + /** + * Compile a "where like" clause. + */ + protected function whereLike(Builder $query, array $where): string + { + if ($where['caseSensitive'] == false) { + return parent::whereLike($query, $where); + } + $where['operator'] = $where['not'] ? 'not glob' : 'glob'; + + return $this->whereBasic($query, $where); + } + + /** + * Convert a LIKE pattern to a GLOB pattern using simple string replacement. + */ + public function prepareWhereLikeBinding(string $value, bool $caseSensitive): string + { + return $caseSensitive === false ? $value : str_replace( + ['*', '?', '%', '_'], + ['[*]', '[?]', '*', '?'], + $value + ); + } + + /** + * Compile a "where date" clause. + */ + protected function whereDate(Builder $query, array $where): string + { + return $this->dateBasedWhere('%Y-%m-%d', $query, $where); + } + + /** + * Compile a "where day" clause. + */ + protected function whereDay(Builder $query, array $where): string + { + return $this->dateBasedWhere('%d', $query, $where); + } + + /** + * Compile a "where month" clause. + */ + protected function whereMonth(Builder $query, array $where): string + { + return $this->dateBasedWhere('%m', $query, $where); + } + + /** + * Compile a "where year" clause. + */ + protected function whereYear(Builder $query, array $where): string + { + return $this->dateBasedWhere('%Y', $query, $where); + } + + /** + * Compile a "where time" clause. + */ + protected function whereTime(Builder $query, array $where): string + { + return $this->dateBasedWhere('%H:%M:%S', $query, $where); + } + + /** + * Compile a date based where clause. + */ + protected function dateBasedWhere(string $type, Builder $query, array $where): string + { + $value = $this->parameter($where['value']); + + return "strftime('{$type}', {$this->wrap($where['column'])}) {$where['operator']} cast({$value} as text)"; + } + + /** + * Compile the index hints for the query. + */ + protected function compileIndexHint(Builder $query, IndexHint $indexHint): string + { + return $indexHint->type === 'force' + ? "indexed by {$indexHint->index}" + : ''; + } + + /** + * Compile a "JSON length" statement into SQL. + */ + protected function compileJsonLength(string $column, string $operator, string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'json_array_length(' . $field . $path . ') ' . $operator . ' ' . $value; + } + + /** + * Compile a "JSON contains" statement into SQL. + */ + protected function compileJsonContains(string $column, string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'exists (select 1 from json_each(' . $field . $path . ') where ' . $this->wrap('json_each.value') . ' is ' . $value . ')'; + } + + /** + * Prepare the binding for a "JSON contains" statement. + */ + public function prepareBindingForJsonContains(mixed $binding): mixed + { + return $binding; + } + + /** + * Compile a "JSON contains key" statement into SQL. + */ + protected function compileJsonContainsKey(string $column): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'json_type(' . $field . $path . ') is not null'; + } + + /** + * Compile a group limit clause. + */ + protected function compileGroupLimit(Builder $query): string + { + $version = $query->getConnection()->getServerVersion(); + + if (version_compare($version, '3.25.0', '>=')) { + return parent::compileGroupLimit($query); + } + + $query->groupLimit = null; + + return $this->compileSelect($query); + } + + /** + * Compile an update statement into SQL. + */ + public function compileUpdate(Builder $query, array $values): string + { + if (isset($query->joins) || isset($query->limit)) { + return $this->compileUpdateWithJoinsOrLimit($query, $values); + } + + return parent::compileUpdate($query, $values); + } + + /** + * Compile an insert ignore statement into SQL. + */ + public function compileInsertOrIgnore(Builder $query, array $values): string + { + return Str::replaceFirst('insert', 'insert or ignore', $this->compileInsert($query, $values)); + } + + /** + * Compile an insert ignore statement using a subquery into SQL. + */ + public function compileInsertOrIgnoreUsing(Builder $query, array $columns, string $sql): string + { + return Str::replaceFirst('insert', 'insert or ignore', $this->compileInsertUsing($query, $columns, $sql)); + } + + /** + * Compile the columns for an update statement. + */ + protected function compileUpdateColumns(Builder $query, array $values): string + { + $jsonGroups = $this->groupJsonColumnsForUpdate($values); + + return (new Collection($values)) + ->reject(fn ($value, $key) => $this->isJsonSelector($key)) + ->merge($jsonGroups) + ->map(function ($value, $key) use ($jsonGroups) { + $column = last(explode('.', $key)); + + $value = isset($jsonGroups[$key]) ? $this->compileJsonPatch($column, $value) : $this->parameter($value); + + return $this->wrap($column) . ' = ' . $value; + }) + ->implode(', '); + } + + /** + * Compile an "upsert" statement into SQL. + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update): string + { + $sql = $this->compileInsert($query, $values); + + $sql .= ' on conflict (' . $this->columnize($uniqueBy) . ') do update set '; + + $columns = (new Collection($update))->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value) . ' = ' . $this->wrapValue('excluded') . '.' . $this->wrap($value) + : $this->wrap($key) . ' = ' . $this->parameter($value); + })->implode(', '); + + return $sql . $columns; + } + + /** + * Group the nested JSON columns. + */ + protected function groupJsonColumnsForUpdate(array $values): array + { + $groups = []; + + foreach ($values as $key => $value) { + if ($this->isJsonSelector($key)) { + Arr::set($groups, str_replace('->', '.', Str::after($key, '.')), $value); + } + } + + return $groups; + } + + /** + * Compile a "JSON" patch statement into SQL. + */ + protected function compileJsonPatch(string $column, mixed $value): string + { + return "json_patch(ifnull({$this->wrap($column)}, json('{}')), json({$this->parameter($value)}))"; + } + + /** + * Compile an update statement with joins or limit into SQL. + */ + protected function compileUpdateWithJoinsOrLimit(Builder $query, array $values): string + { + $table = $this->wrapTable($query->from); + + $columns = $this->compileUpdateColumns($query, $values); + + $alias = last(preg_split('/\s+as\s+/i', $query->from)); + + $selectSql = $this->compileSelect($query->select($alias . '.rowid')); + + return "update {$table} set {$columns} where {$this->wrap('rowid')} in ({$selectSql})"; + } + + /** + * Prepare the bindings for an update statement. + */ + #[Override] + public function prepareBindingsForUpdate(array $bindings, array $values): array + { + $groups = $this->groupJsonColumnsForUpdate($values); + + $values = (new Collection($values)) + ->reject(fn ($value, $key) => $this->isJsonSelector($key)) + ->merge($groups) + ->map(fn ($value) => is_array($value) ? json_encode($value) : $value) + ->all(); + + $cleanBindings = Arr::except($bindings, 'select'); + + $values = Arr::flatten(array_map(fn ($value) => value($value), $values)); + + return array_values( + array_merge($values, Arr::flatten($cleanBindings)) + ); + } + + /** + * Compile a delete statement into SQL. + */ + public function compileDelete(Builder $query): string + { + if (isset($query->joins) || isset($query->limit)) { + return $this->compileDeleteWithJoinsOrLimit($query); + } + + return parent::compileDelete($query); + } + + /** + * Compile a delete statement with joins or limit into SQL. + */ + protected function compileDeleteWithJoinsOrLimit(Builder $query): string + { + $table = $this->wrapTable($query->from); + + $alias = last(preg_split('/\s+as\s+/i', $query->from)); + + $selectSql = $this->compileSelect($query->select($alias . '.rowid')); + + return "delete from {$table} where {$this->wrap('rowid')} in ({$selectSql})"; + } + + /** + * Compile a truncate table statement into SQL. + */ + public function compileTruncate(Builder $query): array + { + [$schema, $table] = $query->getConnection()->getSchemaBuilder()->parseSchemaAndTable($query->from); + + $schema = $schema ? $this->wrapValue($schema) . '.' : ''; + + return [ + 'delete from ' . $schema . 'sqlite_sequence where name = ?' => [$query->getConnection()->getTablePrefix() . $table], + 'delete from ' . $this->wrapTable($query->from) => [], + ]; + } + + /** + * Wrap the given JSON selector. + */ + protected function wrapJsonSelector(string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_extract(' . $field . $path . ')'; + } +} diff --git a/src/database/src/Query/IndexHint.php b/src/database/src/Query/IndexHint.php new file mode 100644 index 000000000..091ca723d --- /dev/null +++ b/src/database/src/Query/IndexHint.php @@ -0,0 +1,17 @@ +type = $type; + $this->table = $table; + $this->parentClass = get_class($parentQuery); + $this->parentGrammar = $parentQuery->getGrammar(); + $this->parentProcessor = $parentQuery->getProcessor(); + $this->parentConnection = $parentQuery->getConnection(); + + parent::__construct( + $this->parentConnection, + $this->parentGrammar, + $this->parentProcessor + ); + } + + /** + * Add an "on" clause to the join. + * + * On clauses can be chained, e.g. + * + * $join->on('contacts.user_id', '=', 'users.id') + * ->on('contacts.info_id', '=', 'info.id') + * + * will produce the following SQL: + * + * on `contacts`.`user_id` = `users`.`id` and `contacts`.`info_id` = `info`.`id` + * + * @throws InvalidArgumentException + */ + public function on( + Closure|ExpressionContract|string $first, + ?string $operator = null, + ExpressionContract|string|null $second = null, + string $boolean = 'and', + ): static { + if ($first instanceof Closure) { + return $this->whereNested($first, $boolean); + } + + return $this->whereColumn($first, $operator, $second, $boolean); + } + + /** + * Add an "or on" clause to the join. + */ + public function orOn( + Closure|ExpressionContract|string $first, + ?string $operator = null, + ExpressionContract|string|null $second = null, + ): static { + return $this->on($first, $operator, $second, 'or'); + } + + /** + * Get a new instance of the join clause builder. + */ + public function newQuery(): static + { + return new static($this->newParentQuery(), $this->type, $this->table); + } + + /** + * Create a new query instance for sub-query. + */ + protected function forSubQuery(): Builder + { + return $this->newParentQuery()->newQuery(); + } + + /** + * Create a new parent query instance. + */ + protected function newParentQuery(): Builder + { + $class = $this->parentClass; + + return new $class($this->parentConnection, $this->parentGrammar, $this->parentProcessor); + } +} diff --git a/src/database/src/Query/JoinLateralClause.php b/src/database/src/Query/JoinLateralClause.php new file mode 100644 index 000000000..7f8c9b54a --- /dev/null +++ b/src/database/src/Query/JoinLateralClause.php @@ -0,0 +1,9 @@ +column_name; + }, $results); + } + + /** + * Process an "insert get ID" query. + */ + #[Override] + public function processInsertGetId(Builder $query, string $sql, array $values, ?string $sequence = null): int|string + { + // @phpstan-ignore arguments.count (MySqlConnection::insert() accepts $sequence param) + $query->getConnection()->insert($sql, $values, $sequence); + + // @phpstan-ignore method.notFound (MySqlProcessor is only used with MySqlConnection) + $id = $query->getConnection()->getLastInsertId(); + + return is_numeric($id) ? (int) $id : $id; + } + + #[Override] + public function processColumns(array $results, string $sql = ''): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'type_name' => $result->type_name, + 'type' => $result->type, + 'collation' => $result->collation, + 'nullable' => $result->nullable === 'YES', + 'default' => $result->default, + 'auto_increment' => $result->extra === 'auto_increment', + 'comment' => $result->comment ?: null, + 'generation' => $result->expression ? [ + 'type' => match ($result->extra) { + 'STORED GENERATED' => 'stored', + 'VIRTUAL GENERATED' => 'virtual', + default => null, + }, + 'expression' => $result->expression, + ] : null, + ]; + }, $results); + } + + #[Override] + public function processIndexes(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $name = strtolower($result->name), + 'columns' => $result->columns ? explode(',', $result->columns) : [], + 'type' => strtolower($result->type), + 'unique' => (bool) $result->unique, + 'primary' => $name === 'primary', + ]; + }, $results); + } + + #[Override] + public function processForeignKeys(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'columns' => explode(',', $result->columns), + 'foreign_schema' => $result->foreign_schema, + 'foreign_table' => $result->foreign_table, + 'foreign_columns' => explode(',', $result->foreign_columns), + 'on_update' => strtolower($result->on_update), + 'on_delete' => strtolower($result->on_delete), + ]; + }, $results); + } +} diff --git a/src/database/src/Query/Processors/PostgresProcessor.php b/src/database/src/Query/Processors/PostgresProcessor.php new file mode 100755 index 000000000..3ffe11f6a --- /dev/null +++ b/src/database/src/Query/Processors/PostgresProcessor.php @@ -0,0 +1,151 @@ +getConnection(); + + $connection->recordsHaveBeenModified(); + + $result = $connection->selectFromWriteConnection($sql, $values)[0]; + + $sequence = $sequence ?: 'id'; + + $id = is_object($result) ? $result->{$sequence} : $result[$sequence]; + + return is_numeric($id) ? (int) $id : $id; + } + + #[Override] + public function processTypes(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'schema' => $result->schema, + 'schema_qualified_name' => $result->schema . '.' . $result->name, + 'implicit' => (bool) $result->implicit, + 'type' => match (strtolower($result->type)) { + 'b' => 'base', + 'c' => 'composite', + 'd' => 'domain', + 'e' => 'enum', + 'p' => 'pseudo', + 'r' => 'range', + 'm' => 'multirange', + default => null, + }, + 'category' => match (strtolower($result->category)) { + 'a' => 'array', + 'b' => 'boolean', + 'c' => 'composite', + 'd' => 'date_time', + 'e' => 'enum', + 'g' => 'geometric', + 'i' => 'network_address', + 'n' => 'numeric', + 'p' => 'pseudo', + 'r' => 'range', + 's' => 'string', + 't' => 'timespan', + 'u' => 'user_defined', + 'v' => 'bit_string', + 'x' => 'unknown', + 'z' => 'internal_use', + default => null, + }, + ]; + }, $results); + } + + #[Override] + public function processColumns(array $results, string $sql = ''): array + { + return array_map(function ($result) { + $result = (object) $result; + + $autoincrement = $result->default !== null && str_starts_with($result->default, 'nextval('); + + return [ + 'name' => $result->name, + 'type_name' => $result->type_name, + 'type' => $result->type, + 'collation' => $result->collation, + 'nullable' => (bool) $result->nullable, + 'default' => $result->generated ? null : $result->default, + 'auto_increment' => $autoincrement, + 'comment' => $result->comment, + 'generation' => $result->generated ? [ + 'type' => match ($result->generated) { + 's' => 'stored', + 'v' => 'virtual', + default => null, + }, + 'expression' => $result->default, + ] : null, + ]; + }, $results); + } + + #[Override] + public function processIndexes(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => strtolower($result->name), + 'columns' => $result->columns ? explode(',', $result->columns) : [], + 'type' => strtolower($result->type), + 'unique' => (bool) $result->unique, + 'primary' => (bool) $result->primary, + ]; + }, $results); + } + + #[Override] + public function processForeignKeys(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'columns' => explode(',', $result->columns), + 'foreign_schema' => $result->foreign_schema, + 'foreign_table' => $result->foreign_table, + 'foreign_columns' => explode(',', $result->foreign_columns), + 'on_update' => match (strtolower($result->on_update)) { + 'a' => 'no action', + 'r' => 'restrict', + 'c' => 'cascade', + 'n' => 'set null', + 'd' => 'set default', + default => null, + }, + 'on_delete' => match (strtolower($result->on_delete)) { + 'a' => 'no action', + 'r' => 'restrict', + 'c' => 'cascade', + 'n' => 'set null', + 'd' => 'set default', + default => null, + }, + ]; + }, $results); + } +} diff --git a/src/database/src/Query/Processors/Processor.php b/src/database/src/Query/Processors/Processor.php new file mode 100755 index 000000000..12fa4d2c6 --- /dev/null +++ b/src/database/src/Query/Processors/Processor.php @@ -0,0 +1,136 @@ +getConnection()->insert($sql, $values); + + $id = $query->getConnection()->getPdo()->lastInsertId($sequence); + + return is_numeric($id) ? (int) $id : $id; + } + + /** + * Process the results of a schemas query. + * + * @param list> $results + * @return list + */ + public function processSchemas(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'path' => $result->path ?? null, // SQLite Only... + 'default' => (bool) $result->default, + ]; + }, $results); + } + + /** + * Process the results of a tables query. + * + * @param list> $results + * @return list + */ + public function processTables(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'schema' => $result->schema ?? null, + 'schema_qualified_name' => isset($result->schema) ? $result->schema . '.' . $result->name : $result->name, + 'size' => isset($result->size) ? (int) $result->size : null, + 'comment' => $result->comment ?? null, // MySQL and PostgreSQL + 'collation' => $result->collation ?? null, // MySQL only + 'engine' => $result->engine ?? null, // MySQL only + ]; + }, $results); + } + + /** + * Process the results of a views query. + * + * @param list> $results + * @return list + */ + public function processViews(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'schema' => $result->schema ?? null, + 'schema_qualified_name' => isset($result->schema) ? $result->schema . '.' . $result->name : $result->name, + 'definition' => $result->definition, + ]; + }, $results); + } + + /** + * Process the results of a types query. + * + * @param list> $results + * @return list + */ + public function processTypes(array $results): array + { + return $results; + } + + /** + * Process the results of a columns query. + * + * @param list> $results + * @return list + */ + public function processColumns(array $results, string $sql = ''): array + { + return $results; + } + + /** + * Process the results of an indexes query. + * + * @param list> $results + * @return list, type: null|string, unique: bool, primary: bool}> + */ + public function processIndexes(array $results): array + { + return $results; + } + + /** + * Process the results of a foreign keys query. + * + * @param list> $results + * @return list, foreign_schema: string, foreign_table: string, foreign_columns: list, on_update: string, on_delete: string}> + */ + public function processForeignKeys(array $results): array + { + return $results; + } +} diff --git a/src/database/src/Query/Processors/SQLiteProcessor.php b/src/database/src/Query/Processors/SQLiteProcessor.php new file mode 100644 index 000000000..16ae90d07 --- /dev/null +++ b/src/database/src/Query/Processors/SQLiteProcessor.php @@ -0,0 +1,103 @@ +type); + + $safeName = preg_quote($result->name, '/'); + + $collation = preg_match( + '/\b' . $safeName . '\b[^,(]+(?:\([^()]+\)[^,]*)?(?:(?:default|check|as)\s*(?:\(.*?\))?[^,]*)*collate\s+["\'`]?(\w+)/i', + $sql, + $matches + ) === 1 ? strtolower($matches[1]) : null; + + $isGenerated = in_array($result->extra, [2, 3]); + + $expression = $isGenerated && preg_match( + '/\b' . $safeName . '\b[^,]+\s+as\s+\(((?:[^()]+|\((?:[^()]+|\([^()]*\))*\))*)\)/i', + $sql, + $matches + ) === 1 ? $matches[1] : null; + + return [ + 'name' => $result->name, + 'type_name' => strtok($type, '(') ?: '', + 'type' => $type, + 'collation' => $collation, + 'nullable' => (bool) $result->nullable, + 'default' => $result->default, + 'auto_increment' => $hasPrimaryKey && $result->primary && $type === 'integer', + 'comment' => null, + 'generation' => $isGenerated ? [ + 'type' => match ((int) $result->extra) { + 3 => 'stored', + 2 => 'virtual', + default => null, + }, + 'expression' => $expression, + ] : null, + ]; + }, $results); + } + + #[Override] + public function processIndexes(array $results): array + { + $primaryCount = 0; + + $indexes = array_map(function ($result) use (&$primaryCount) { + $result = (object) $result; + + if ($isPrimary = (bool) $result->primary) { + ++$primaryCount; + } + + return [ + 'name' => strtolower($result->name), + 'columns' => $result->columns ? explode(',', $result->columns) : [], + 'type' => null, + 'unique' => (bool) $result->unique, + 'primary' => $isPrimary, + ]; + }, $results); + + if ($primaryCount > 1) { + $indexes = array_filter($indexes, fn ($index) => $index['name'] !== 'primary'); + } + + return $indexes; + } + + #[Override] + public function processForeignKeys(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => null, + 'columns' => explode(',', $result->columns), + 'foreign_schema' => $result->foreign_schema, + 'foreign_table' => $result->foreign_table, + 'foreign_columns' => explode(',', $result->foreign_columns), + 'on_update' => strtolower($result->on_update), + 'on_delete' => strtolower($result->on_delete), + ]; + }, $results); + } +} diff --git a/src/database/src/QueryException.php b/src/database/src/QueryException.php new file mode 100644 index 000000000..2b8dcaa8a --- /dev/null +++ b/src/database/src/QueryException.php @@ -0,0 +1,149 @@ +connectionName = $connectionName; + $this->sql = $sql; + $this->bindings = $bindings; + $this->connectionDetails = $connectionDetails; + $this->readWriteType = $readWriteType; + $this->code = $previous->getCode(); + $this->message = $this->formatMessage($connectionName, $sql, $bindings, $previous); + + if ($previous instanceof PDOException) { + $this->errorInfo = $previous->errorInfo; + } + } + + /** + * Format the SQL error message. + */ + protected function formatMessage(string $connectionName, string $sql, array $bindings, Throwable $previous): string + { + $details = $this->formatConnectionDetails(); + + return $previous->getMessage() . ' (Connection: ' . $connectionName . $details . ', SQL: ' . Str::replaceArray('?', $bindings, $sql) . ')'; + } + + /** + * Format the connection details for the error message. + */ + protected function formatConnectionDetails(): string + { + if (empty($this->connectionDetails)) { + return ''; + } + + $driver = $this->connectionDetails['driver'] ?? ''; + + $segments = []; + + if ($driver !== 'sqlite') { + if (! empty($this->connectionDetails['unix_socket'])) { + $segments[] = 'Socket: ' . $this->connectionDetails['unix_socket']; + } else { + $host = $this->connectionDetails['host'] ?? ''; + + $segments[] = 'Host: ' . (is_array($host) ? implode(', ', $host) : $host); + $segments[] = 'Port: ' . ($this->connectionDetails['port'] ?? ''); + } + } + + $segments[] = 'Database: ' . ($this->connectionDetails['database'] ?? ''); + + return ', ' . implode(', ', $segments); + } + + /** + * Get the connection name for the query. + */ + public function getConnectionName(): string + { + return $this->connectionName; + } + + /** + * Get the SQL for the query. + */ + public function getSql(): string + { + return $this->sql; + } + + /** + * Get the raw SQL representation of the query with embedded bindings. + */ + public function getRawSql(): string + { + return DB::connection($this->getConnectionName()) + ->getQueryGrammar() + ->substituteBindingsIntoRawSql($this->getSql(), $this->getBindings()); + } + + /** + * Get the bindings for the query. + */ + public function getBindings(): array + { + return $this->bindings; + } + + /** + * Get information about the connection such as host, port, database, etc. + */ + public function getConnectionDetails(): array + { + return $this->connectionDetails; + } +} diff --git a/src/database/src/RecordNotFoundException.php b/src/database/src/RecordNotFoundException.php new file mode 100644 index 000000000..4348e118d --- /dev/null +++ b/src/database/src/RecordNotFoundException.php @@ -0,0 +1,11 @@ +=')) { + $mode = $this->getConfig('transaction_mode') ?? 'DEFERRED'; + + $this->getPdo()->exec("BEGIN {$mode} TRANSACTION"); + + return; + } + + $this->getPdo()->beginTransaction(); + } + + /** + * Escape a binary value for safe SQL embedding. + */ + protected function escapeBinary(string $value): string + { + $hex = bin2hex($value); + + return "x'{$hex}'"; + } + + /** + * Determine if the given database exception was caused by a unique constraint violation. + */ + protected function isUniqueConstraintError(Exception $exception): bool + { + return (bool) preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage()); + } + + /** + * Get the default query grammar instance. + */ + protected function getDefaultQueryGrammar(): SQLiteGrammar + { + return new SQLiteGrammar($this); + } + + /** + * Get a schema builder instance for the connection. + */ + public function getSchemaBuilder(): SQLiteBuilder + { + if (is_null($this->schemaGrammar)) { + $this->useDefaultSchemaGrammar(); + } + + return new SQLiteBuilder($this); + } + + /** + * Get the default schema grammar instance. + */ + protected function getDefaultSchemaGrammar(): SQLiteSchemaGrammar + { + return new SQLiteSchemaGrammar($this); + } + + /** + * Get the schema state for the connection. + */ + #[Override] + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null): SqliteSchemaState + { + return new SqliteSchemaState($this, $files, $processFactory); + } + + /** + * Get the default post processor instance. + */ + protected function getDefaultPostProcessor(): SQLiteProcessor + { + return new SQLiteProcessor(); + } +} diff --git a/src/database/src/SQLiteDatabaseDoesNotExistException.php b/src/database/src/SQLiteDatabaseDoesNotExistException.php new file mode 100644 index 000000000..dafaa380c --- /dev/null +++ b/src/database/src/SQLiteDatabaseDoesNotExistException.php @@ -0,0 +1,25 @@ +path = $path; + } +} diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php new file mode 100755 index 000000000..c29b21792 --- /dev/null +++ b/src/database/src/Schema/Blueprint.php @@ -0,0 +1,1531 @@ +connection = $connection; + $this->grammar = $connection->getSchemaGrammar(); + $this->table = $table; + + if (! is_null($callback)) { + $callback($this); + } + } + + /** + * Execute the blueprint against the database. + */ + public function build(): void + { + foreach ($this->toSql() as $statement) { + $this->connection->statement($statement); + } + } + + /** + * Get the raw SQL statements for the blueprint. + */ + public function toSql(): array + { + $this->addImpliedCommands(); + + $statements = []; + + // Each type of command has a corresponding compiler function on the schema + // grammar which is used to build the necessary SQL statements to build + // the blueprint element, so we'll just call that compilers function. + $this->ensureCommandsAreValid(); + + foreach ($this->commands as $command) { + if ($command->shouldBeSkipped) { + continue; + } + + $method = 'compile' . ucfirst($command->name); + + if (method_exists($this->grammar, $method) || $this->grammar::hasMacro($method)) { + if ($this->hasState()) { + $this->state->update($command); + } + + if (! is_null($sql = $this->grammar->{$method}($this, $command))) { + $statements = array_merge($statements, (array) $sql); + } + } + } + + return $statements; + } + + /** + * Ensure the commands on the blueprint are valid for the connection type. + * + * @throws BadMethodCallException + */ + protected function ensureCommandsAreValid(): void + { + } + + /** + * Get all of the commands matching the given names. + * + * @deprecated will be removed in a future Laravel version + */ + protected function commandsNamed(array $names): Collection + { + return (new Collection($this->commands)) + ->filter(fn ($command) => in_array($command->name, $names)); + } + + /** + * Add the commands that are implied by the blueprint's state. + */ + protected function addImpliedCommands(): void + { + $this->addFluentIndexes(); + $this->addFluentCommands(); + + if (! $this->creating()) { + $this->commands = array_map( + fn ($command) => $command instanceof ColumnDefinition + ? $this->createCommand($command->change ? 'change' : 'add', ['column' => $command]) + : $command, + $this->commands + ); + + $this->addAlterCommands(); + } + } + + /** + * Add the index commands fluently specified on columns. + */ + protected function addFluentIndexes(): void + { + foreach ($this->columns as $column) { + foreach (['primary', 'unique', 'index', 'fulltext', 'fullText', 'spatialIndex', 'vectorIndex'] as $index) { + // If the column is supposed to be changed to an auto increment column and + // the specified index is primary, there is no need to add a command on + // MySQL, as it will be handled during the column definition instead. + if ($index === 'primary' && $column->autoIncrement && $column->change && $this->grammar instanceof MySqlGrammar) { + continue 2; + } + + // If the index has been specified on the given column, but is simply equal + // to "true" (boolean), no name has been specified for this index so the + // index method can be called without a name and it will generate one. + if ($column->{$index} === true) { + $indexMethod = $index === 'index' && $column->type === 'vector' + ? 'vectorIndex' + : $index; + + $this->{$indexMethod}($column->name); + $column->{$index} = null; + + continue 2; + } + + // If the index has been specified on the given column, but it equals false + // and the column is supposed to be changed, we will call the drop index + // method with an array of column to drop it by its conventional name. + if ($column->{$index} === false && $column->change) { + $this->{'drop' . ucfirst($index)}([$column->name]); + $column->{$index} = null; + + continue 2; + } + + // If the index has been specified on the given column, and it has a string + // value, we'll go ahead and call the index method and pass the name for + // the index since the developer specified the explicit name for this. + if (isset($column->{$index})) { + $indexMethod = $index === 'index' && $column->type === 'vector' + ? 'vectorIndex' + : $index; + + $this->{$indexMethod}($column->name, $column->{$index}); + $column->{$index} = null; + + continue 2; + } + } + } + } + + /** + * Add the fluent commands specified on any columns. + */ + public function addFluentCommands(): void + { + foreach ($this->columns as $column) { + foreach ($this->grammar->getFluentCommands() as $commandName) { + $this->addCommand($commandName, compact('column')); + } + } + } + + /** + * Add the alter commands if whenever needed. + */ + public function addAlterCommands(): void + { + if (! $this->grammar instanceof SQLiteGrammar) { + return; + } + + $alterCommands = $this->grammar->getAlterCommands(); + + [$commands, $lastCommandWasAlter, $hasAlterCommand] = [ + [], false, false, + ]; + + foreach ($this->commands as $command) { + if (in_array($command->name, $alterCommands)) { + $hasAlterCommand = true; + $lastCommandWasAlter = true; + } elseif ($lastCommandWasAlter) { + $commands[] = $this->createCommand('alter'); + $lastCommandWasAlter = false; + } + + $commands[] = $command; + } + + if ($lastCommandWasAlter) { + $commands[] = $this->createCommand('alter'); + } + + if ($hasAlterCommand) { + $this->state = new BlueprintState($this, $this->connection); + } + + $this->commands = $commands; + } + + /** + * Determine if the blueprint has a create command. + */ + public function creating(): bool + { + return (new Collection($this->commands)) + ->contains(fn ($command) => ! $command instanceof ColumnDefinition && $command->name === 'create'); + } + + /** + * Indicate that the table needs to be created. + */ + public function create(): Fluent + { + return $this->addCommand('create'); + } + + /** + * Specify the storage engine that should be used for the table. + */ + public function engine(string $engine): void + { + $this->engine = $engine; + } + + /** + * Specify that the InnoDB storage engine should be used for the table (MySQL only). + */ + public function innoDb(): void + { + $this->engine('InnoDB'); + } + + /** + * Specify the character set that should be used for the table. + */ + public function charset(string $charset): void + { + $this->charset = $charset; + } + + /** + * Specify the collation that should be used for the table. + */ + public function collation(string $collation): void + { + $this->collation = $collation; + } + + /** + * Indicate that the table needs to be temporary. + */ + public function temporary(): void + { + $this->temporary = true; + } + + /** + * Indicate that the table should be dropped. + */ + public function drop(): Fluent + { + return $this->addCommand('drop'); + } + + /** + * Indicate that the table should be dropped if it exists. + */ + public function dropIfExists(): Fluent + { + return $this->addCommand('dropIfExists'); + } + + /** + * Indicate that the given columns should be dropped. + */ + public function dropColumn(array|string $columns): Fluent + { + $columns = is_array($columns) ? $columns : func_get_args(); + + return $this->addCommand('dropColumn', compact('columns')); + } + + /** + * Indicate that the given columns should be renamed. + */ + public function renameColumn(string $from, string $to): Fluent + { + return $this->addCommand('renameColumn', compact('from', 'to')); + } + + /** + * Indicate that the given primary key should be dropped. + */ + public function dropPrimary(array|string|null $index = null): Fluent + { + return $this->dropIndexCommand('dropPrimary', 'primary', $index); + } + + /** + * Indicate that the given unique key should be dropped. + */ + public function dropUnique(array|string $index): Fluent + { + return $this->dropIndexCommand('dropUnique', 'unique', $index); + } + + /** + * Indicate that the given index should be dropped. + */ + public function dropIndex(array|string $index): Fluent + { + return $this->dropIndexCommand('dropIndex', 'index', $index); + } + + /** + * Indicate that the given fulltext index should be dropped. + */ + public function dropFullText(array|string $index): Fluent + { + return $this->dropIndexCommand('dropFullText', 'fulltext', $index); + } + + /** + * Indicate that the given spatial index should be dropped. + */ + public function dropSpatialIndex(array|string $index): Fluent + { + return $this->dropIndexCommand('dropSpatialIndex', 'spatialIndex', $index); + } + + /** + * Indicate that the given vector index should be dropped. + */ + public function dropVectorIndex(array|string $index): Fluent + { + return $this->dropIndexCommand('dropVectorIndex', 'vectorIndex', $index); + } + + /** + * Indicate that the given foreign key should be dropped. + */ + public function dropForeign(array|string $index): Fluent + { + return $this->dropIndexCommand('dropForeign', 'foreign', $index); + } + + /** + * Indicate that the given column and foreign key should be dropped. + */ + public function dropConstrainedForeignId(string $column): Fluent + { + $this->dropForeign([$column]); + + return $this->dropColumn($column); + } + + /** + * Indicate that the given foreign key should be dropped. + */ + public function dropForeignIdFor(object|string $model, ?string $column = null): Fluent + { + if (is_string($model)) { + $model = new $model(); + } + + return $this->dropColumn($column ?: $model->getForeignKey()); + } + + /** + * Indicate that the given foreign key should be dropped. + */ + public function dropConstrainedForeignIdFor(object|string $model, ?string $column = null): Fluent + { + if (is_string($model)) { + $model = new $model(); + } + + return $this->dropConstrainedForeignId($column ?: $model->getForeignKey()); + } + + /** + * Indicate that the given indexes should be renamed. + */ + public function renameIndex(string $from, string $to): Fluent + { + return $this->addCommand('renameIndex', compact('from', 'to')); + } + + /** + * Indicate that the timestamp columns should be dropped. + */ + public function dropTimestamps(): void + { + $this->dropColumn('created_at', 'updated_at'); + } + + /** + * Indicate that the timestamp columns should be dropped. + */ + public function dropTimestampsTz(): void + { + $this->dropTimestamps(); + } + + /** + * Indicate that the soft delete column should be dropped. + */ + public function dropSoftDeletes(string $column = 'deleted_at'): void + { + $this->dropColumn($column); + } + + /** + * Indicate that the soft delete column should be dropped. + */ + public function dropSoftDeletesTz(string $column = 'deleted_at'): void + { + $this->dropSoftDeletes($column); + } + + /** + * Indicate that the remember token column should be dropped. + */ + public function dropRememberToken(): void + { + $this->dropColumn('remember_token'); + } + + /** + * Indicate that the polymorphic columns should be dropped. + */ + public function dropMorphs(string $name, ?string $indexName = null): void + { + $this->dropIndex($indexName ?: $this->createIndexName('index', ["{$name}_type", "{$name}_id"])); + + $this->dropColumn("{$name}_type", "{$name}_id"); + } + + /** + * Rename the table to a given name. + */ + public function rename(string $to): Fluent + { + return $this->addCommand('rename', compact('to')); + } + + /** + * Specify the primary key(s) for the table. + */ + public function primary(array|string $columns, ?string $name = null, ?string $algorithm = null): Fluent + { + return $this->indexCommand('primary', $columns, $name, $algorithm); + } + + /** + * Specify a unique index for the table. + */ + public function unique(array|string $columns, ?string $name = null, ?string $algorithm = null): Fluent + { + return $this->indexCommand('unique', $columns, $name, $algorithm); + } + + /** + * Specify an index for the table. + */ + public function index(array|string $columns, ?string $name = null, ?string $algorithm = null): Fluent + { + return $this->indexCommand('index', $columns, $name, $algorithm); + } + + /** + * Specify a fulltext index for the table. + */ + public function fullText(array|string $columns, ?string $name = null, ?string $algorithm = null): Fluent + { + return $this->indexCommand('fulltext', $columns, $name, $algorithm); + } + + /** + * Specify a spatial index for the table. + */ + public function spatialIndex(array|string $columns, ?string $name = null, ?string $operatorClass = null): Fluent + { + return $this->indexCommand('spatialIndex', $columns, $name, null, $operatorClass); + } + + /** + * Specify a vector index for the table. + */ + public function vectorIndex(string $column, ?string $name = null): Fluent + { + return $this->indexCommand('vectorIndex', $column, $name, 'hnsw', 'vector_cosine_ops'); + } + + /** + * Specify a raw index for the table. + */ + public function rawIndex(string $expression, string $name): Fluent + { + return $this->index([new Expression($expression)], $name); + } + + /** + * Specify a foreign key for the table. + */ + public function foreign(array|string $columns, ?string $name = null): ForeignKeyDefinition + { + $command = new ForeignKeyDefinition( + $this->indexCommand('foreign', $columns, $name)->getAttributes() + ); + + $this->commands[count($this->commands) - 1] = $command; + + return $command; + } + + /** + * Create a new auto-incrementing big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). + */ + public function id(string $column = 'id'): ColumnDefinition + { + return $this->bigIncrements($column); + } + + /** + * Create a new auto-incrementing integer column on the table (4-byte, 0 to 4,294,967,295). + */ + public function increments(string $column): ColumnDefinition + { + return $this->unsignedInteger($column, true); + } + + /** + * Create a new auto-incrementing integer column on the table (4-byte, 0 to 4,294,967,295). + */ + public function integerIncrements(string $column): ColumnDefinition + { + return $this->unsignedInteger($column, true); + } + + /** + * Create a new auto-incrementing tiny integer column on the table (1-byte, 0 to 255). + */ + public function tinyIncrements(string $column): ColumnDefinition + { + return $this->unsignedTinyInteger($column, true); + } + + /** + * Create a new auto-incrementing small integer column on the table (2-byte, 0 to 65,535). + */ + public function smallIncrements(string $column): ColumnDefinition + { + return $this->unsignedSmallInteger($column, true); + } + + /** + * Create a new auto-incrementing medium integer column on the table (3-byte, 0 to 16,777,215). + */ + public function mediumIncrements(string $column): ColumnDefinition + { + return $this->unsignedMediumInteger($column, true); + } + + /** + * Create a new auto-incrementing big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). + */ + public function bigIncrements(string $column): ColumnDefinition + { + return $this->unsignedBigInteger($column, true); + } + + /** + * Create a new char column on the table. + */ + public function char(string $column, ?int $length = null): ColumnDefinition + { + $length = ! is_null($length) ? $length : Builder::$defaultStringLength; + + return $this->addColumn('char', $column, compact('length')); + } + + /** + * Create a new string column on the table. + */ + public function string(string $column, ?int $length = null): ColumnDefinition + { + $length = $length ?: Builder::$defaultStringLength; + + return $this->addColumn('string', $column, compact('length')); + } + + /** + * Create a new tiny text column on the table (up to 255 characters). + */ + public function tinyText(string $column): ColumnDefinition + { + return $this->addColumn('tinyText', $column); + } + + /** + * Create a new text column on the table (up to 65,535 characters / ~64 KB). + */ + public function text(string $column): ColumnDefinition + { + return $this->addColumn('text', $column); + } + + /** + * Create a new medium text column on the table (up to 16,777,215 characters / ~16 MB). + */ + public function mediumText(string $column): ColumnDefinition + { + return $this->addColumn('mediumText', $column); + } + + /** + * Create a new long text column on the table (up to 4,294,967,295 characters / ~4 GB). + */ + public function longText(string $column): ColumnDefinition + { + return $this->addColumn('longText', $column); + } + + /** + * Create a new integer (4-byte) column on the table. + * Range: -2,147,483,648 to 2,147,483,647 (signed) or 0 to 4,294,967,295 (unsigned). + */ + public function integer(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition + { + return $this->addColumn('integer', $column, compact('autoIncrement', 'unsigned')); + } + + /** + * Create a new tiny integer (1-byte) column on the table. + * Range: -128 to 127 (signed) or 0 to 255 (unsigned). + */ + public function tinyInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition + { + return $this->addColumn('tinyInteger', $column, compact('autoIncrement', 'unsigned')); + } + + /** + * Create a new small integer (2-byte) column on the table. + * Range: -32,768 to 32,767 (signed) or 0 to 65,535 (unsigned). + */ + public function smallInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition + { + return $this->addColumn('smallInteger', $column, compact('autoIncrement', 'unsigned')); + } + + /** + * Create a new medium integer (3-byte) column on the table. + * Range: -8,388,608 to 8,388,607 (signed) or 0 to 16,777,215 (unsigned). + */ + public function mediumInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition + { + return $this->addColumn('mediumInteger', $column, compact('autoIncrement', 'unsigned')); + } + + /** + * Create a new big integer (8-byte) column on the table. + * Range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 (signed) or 0 to 18,446,744,073,709,551,615 (unsigned). + */ + public function bigInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition + { + return $this->addColumn('bigInteger', $column, compact('autoIncrement', 'unsigned')); + } + + /** + * Create a new unsigned integer column on the table (4-byte, 0 to 4,294,967,295). + */ + public function unsignedInteger(string $column, bool $autoIncrement = false): ColumnDefinition + { + return $this->integer($column, $autoIncrement, true); + } + + /** + * Create a new unsigned tiny integer column on the table (1-byte, 0 to 255). + */ + public function unsignedTinyInteger(string $column, bool $autoIncrement = false): ColumnDefinition + { + return $this->tinyInteger($column, $autoIncrement, true); + } + + /** + * Create a new unsigned small integer column on the table (2-byte, 0 to 65,535). + */ + public function unsignedSmallInteger(string $column, bool $autoIncrement = false): ColumnDefinition + { + return $this->smallInteger($column, $autoIncrement, true); + } + + /** + * Create a new unsigned medium integer column on the table (3-byte, 0 to 16,777,215). + */ + public function unsignedMediumInteger(string $column, bool $autoIncrement = false): ColumnDefinition + { + return $this->mediumInteger($column, $autoIncrement, true); + } + + /** + * Create a new unsigned big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). + */ + public function unsignedBigInteger(string $column, bool $autoIncrement = false): ColumnDefinition + { + return $this->bigInteger($column, $autoIncrement, true); + } + + /** + * Create a new unsigned big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). + */ + public function foreignId(string $column): ForeignIdColumnDefinition + { + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'bigInteger', + 'name' => $column, + 'autoIncrement' => false, + 'unsigned' => true, + ])); + } + + /** + * Create a foreign ID column for the given model. + */ + public function foreignIdFor(object|string $model, ?string $column = null): ForeignIdColumnDefinition + { + if (is_string($model)) { + $model = new $model(); + } + + $column = $column ?: $model->getForeignKey(); + + if ($model->getKeyType() === 'int') { + return $this->foreignId($column) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); + } + + $modelTraits = class_uses_recursive($model); + + if (in_array(HasUlids::class, $modelTraits, true)) { + return $this->foreignUlid($column, 26) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); + } + + return $this->foreignUuid($column) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); + } + + /** + * Create a new float column on the table. + */ + public function float(string $column, int $precision = 53): ColumnDefinition + { + return $this->addColumn('float', $column, compact('precision')); + } + + /** + * Create a new double column on the table. + */ + public function double(string $column): ColumnDefinition + { + return $this->addColumn('double', $column); + } + + /** + * Create a new decimal column on the table. + */ + public function decimal(string $column, int $total = 8, int $places = 2): ColumnDefinition + { + return $this->addColumn('decimal', $column, compact('total', 'places')); + } + + /** + * Create a new boolean column on the table. + */ + public function boolean(string $column): ColumnDefinition + { + return $this->addColumn('boolean', $column); + } + + /** + * Create a new enum column on the table. + */ + public function enum(string $column, array $allowed): ColumnDefinition + { + $allowed = array_map(fn ($value) => enum_value($value), $allowed); + + return $this->addColumn('enum', $column, compact('allowed')); + } + + /** + * Create a new set column on the table. + */ + public function set(string $column, array $allowed): ColumnDefinition + { + return $this->addColumn('set', $column, compact('allowed')); + } + + /** + * Create a new json column on the table. + */ + public function json(string $column): ColumnDefinition + { + return $this->addColumn('json', $column); + } + + /** + * Create a new jsonb column on the table. + */ + public function jsonb(string $column): ColumnDefinition + { + return $this->addColumn('jsonb', $column); + } + + /** + * Create a new date column on the table. + */ + public function date(string $column): ColumnDefinition + { + return $this->addColumn('date', $column); + } + + /** + * Create a new date-time column on the table. + */ + public function dateTime(string $column, ?int $precision = null): ColumnDefinition + { + $precision ??= $this->defaultTimePrecision(); + + return $this->addColumn('dateTime', $column, compact('precision')); + } + + /** + * Create a new date-time column (with time zone) on the table. + */ + public function dateTimeTz(string $column, ?int $precision = null): ColumnDefinition + { + $precision ??= $this->defaultTimePrecision(); + + return $this->addColumn('dateTimeTz', $column, compact('precision')); + } + + /** + * Create a new time column on the table. + */ + public function time(string $column, ?int $precision = null): ColumnDefinition + { + $precision ??= $this->defaultTimePrecision(); + + return $this->addColumn('time', $column, compact('precision')); + } + + /** + * Create a new time column (with time zone) on the table. + */ + public function timeTz(string $column, ?int $precision = null): ColumnDefinition + { + $precision ??= $this->defaultTimePrecision(); + + return $this->addColumn('timeTz', $column, compact('precision')); + } + + /** + * Create a new timestamp column on the table. + */ + public function timestamp(string $column, ?int $precision = null): ColumnDefinition + { + $precision ??= $this->defaultTimePrecision(); + + return $this->addColumn('timestamp', $column, compact('precision')); + } + + /** + * Create a new timestamp (with time zone) column on the table. + */ + public function timestampTz(string $column, ?int $precision = null): ColumnDefinition + { + $precision ??= $this->defaultTimePrecision(); + + return $this->addColumn('timestampTz', $column, compact('precision')); + } + + /** + * Add nullable creation and update timestamps to the table. + * + * @return \Hypervel\Support\Collection + */ + public function timestamps(?int $precision = null): Collection + { + return new Collection([ + $this->timestamp('created_at', $precision)->nullable(), + $this->timestamp('updated_at', $precision)->nullable(), + ]); + } + + /** + * Add nullable creation and update timestamps to the table. + * + * Alias for self::timestamps(). + * + * @return \Hypervel\Support\Collection + */ + public function nullableTimestamps(?int $precision = null): Collection + { + return $this->timestamps($precision); + } + + /** + * Add nullable creation and update timestampTz columns to the table. + * + * @return \Hypervel\Support\Collection + */ + public function timestampsTz(?int $precision = null): Collection + { + return new Collection([ + $this->timestampTz('created_at', $precision)->nullable(), + $this->timestampTz('updated_at', $precision)->nullable(), + ]); + } + + /** + * Add nullable creation and update timestampTz columns to the table. + * + * Alias for self::timestampsTz(). + * + * @return \Hypervel\Support\Collection + */ + public function nullableTimestampsTz(?int $precision = null): Collection + { + return $this->timestampsTz($precision); + } + + /** + * Add creation and update datetime columns to the table. + * + * @return \Hypervel\Support\Collection + */ + public function datetimes(?int $precision = null): Collection + { + return new Collection([ + $this->datetime('created_at', $precision)->nullable(), + $this->datetime('updated_at', $precision)->nullable(), + ]); + } + + /** + * Add a "deleted at" timestamp for the table. + */ + public function softDeletes(string $column = 'deleted_at', ?int $precision = null): ColumnDefinition + { + return $this->timestamp($column, $precision)->nullable(); + } + + /** + * Add a "deleted at" timestampTz for the table. + */ + public function softDeletesTz(string $column = 'deleted_at', ?int $precision = null): ColumnDefinition + { + return $this->timestampTz($column, $precision)->nullable(); + } + + /** + * Add a "deleted at" datetime column to the table. + */ + public function softDeletesDatetime(string $column = 'deleted_at', ?int $precision = null): ColumnDefinition + { + return $this->datetime($column, $precision)->nullable(); + } + + /** + * Create a new year column on the table. + */ + public function year(string $column): ColumnDefinition + { + return $this->addColumn('year', $column); + } + + /** + * Create a new binary column on the table. + */ + public function binary(string $column, ?int $length = null, bool $fixed = false): ColumnDefinition + { + return $this->addColumn('binary', $column, compact('length', 'fixed')); + } + + /** + * Create a new UUID column on the table. + */ + public function uuid(string $column = 'uuid'): ColumnDefinition + { + return $this->addColumn('uuid', $column); + } + + /** + * Create a new UUID column on the table with a foreign key constraint. + */ + public function foreignUuid(string $column): ForeignIdColumnDefinition + { + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'uuid', + 'name' => $column, + ])); + } + + /** + * Create a new ULID column on the table. + */ + public function ulid(string $column = 'ulid', ?int $length = 26): ColumnDefinition + { + return $this->char($column, $length); + } + + /** + * Create a new ULID column on the table with a foreign key constraint. + */ + public function foreignUlid(string $column, ?int $length = 26): ForeignIdColumnDefinition + { + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'char', + 'name' => $column, + 'length' => $length, + ])); + } + + /** + * Create a new IP address column on the table. + */ + public function ipAddress(string $column = 'ip_address'): ColumnDefinition + { + return $this->addColumn('ipAddress', $column); + } + + /** + * Create a new MAC address column on the table. + */ + public function macAddress(string $column = 'mac_address'): ColumnDefinition + { + return $this->addColumn('macAddress', $column); + } + + /** + * Create a new geometry column on the table. + */ + public function geometry(string $column, ?string $subtype = null, int $srid = 0): ColumnDefinition + { + return $this->addColumn('geometry', $column, compact('subtype', 'srid')); + } + + /** + * Create a new geography column on the table. + */ + public function geography(string $column, ?string $subtype = null, int $srid = 4326): ColumnDefinition + { + return $this->addColumn('geography', $column, compact('subtype', 'srid')); + } + + /** + * Create a new generated, computed column on the table. + */ + public function computed(string $column, string $expression): ColumnDefinition + { + return $this->addColumn('computed', $column, compact('expression')); + } + + /** + * Create a new vector column on the table. + */ + public function vector(string $column, ?int $dimensions = null): ColumnDefinition + { + $options = $dimensions ? compact('dimensions') : []; + + return $this->addColumn('vector', $column, $options); + } + + /** + * Add the proper columns for a polymorphic table. + */ + public function morphs(string $name, ?string $indexName = null, ?string $after = null): void + { + if (Builder::$defaultMorphKeyType === 'uuid') { + $this->uuidMorphs($name, $indexName, $after); + } elseif (Builder::$defaultMorphKeyType === 'ulid') { + $this->ulidMorphs($name, $indexName, $after); + } else { + $this->numericMorphs($name, $indexName, $after); + } + } + + /** + * Add nullable columns for a polymorphic table. + */ + public function nullableMorphs(string $name, ?string $indexName = null, ?string $after = null): void + { + if (Builder::$defaultMorphKeyType === 'uuid') { + $this->nullableUuidMorphs($name, $indexName, $after); + } elseif (Builder::$defaultMorphKeyType === 'ulid') { + $this->nullableUlidMorphs($name, $indexName, $after); + } else { + $this->nullableNumericMorphs($name, $indexName, $after); + } + } + + /** + * Add the proper columns for a polymorphic table using numeric IDs (incremental). + */ + public function numericMorphs(string $name, ?string $indexName = null, ?string $after = null): void + { + $this->string("{$name}_type") + ->after($after); + + $this->unsignedBigInteger("{$name}_id") + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add nullable columns for a polymorphic table using numeric IDs (incremental). + */ + public function nullableNumericMorphs(string $name, ?string $indexName = null, ?string $after = null): void + { + $this->string("{$name}_type") + ->nullable() + ->after($after); + + $this->unsignedBigInteger("{$name}_id") + ->nullable() + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add the proper columns for a polymorphic table using UUIDs. + */ + public function uuidMorphs(string $name, ?string $indexName = null, ?string $after = null): void + { + $this->string("{$name}_type") + ->after($after); + + $this->uuid("{$name}_id") + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add nullable columns for a polymorphic table using UUIDs. + */ + public function nullableUuidMorphs(string $name, ?string $indexName = null, ?string $after = null): void + { + $this->string("{$name}_type") + ->nullable() + ->after($after); + + $this->uuid("{$name}_id") + ->nullable() + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add the proper columns for a polymorphic table using ULIDs. + */ + public function ulidMorphs(string $name, ?string $indexName = null, ?string $after = null): void + { + $this->string("{$name}_type") + ->after($after); + + $this->ulid("{$name}_id") + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add nullable columns for a polymorphic table using ULIDs. + */ + public function nullableUlidMorphs(string $name, ?string $indexName = null, ?string $after = null): void + { + $this->string("{$name}_type") + ->nullable() + ->after($after); + + $this->ulid("{$name}_id") + ->nullable() + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add the `remember_token` column to the table. + */ + public function rememberToken(): ColumnDefinition + { + return $this->string('remember_token', 100)->nullable(); + } + + /** + * Create a new custom column on the table. + */ + public function rawColumn(string $column, string $definition): ColumnDefinition + { + return $this->addColumn('raw', $column, compact('definition')); + } + + /** + * Add a comment to the table. + */ + public function comment(string $comment): Fluent + { + return $this->addCommand('tableComment', compact('comment')); + } + + /** + * Create a new index command on the blueprint. + */ + protected function indexCommand(string $type, array|string $columns, ?string $index, ?string $algorithm = null, ?string $operatorClass = null): Fluent + { + $columns = (array) $columns; + + // If no name was specified for this index, we will create one using a basic + // convention of the table name, followed by the columns, followed by an + // index type, such as primary or index, which makes the index unique. + $index = $index ?: $this->createIndexName($type, $columns); + + return $this->addCommand( + $type, + compact('index', 'columns', 'algorithm', 'operatorClass') + ); + } + + /** + * Create a new drop index command on the blueprint. + */ + protected function dropIndexCommand(string $command, string $type, array|string $index): Fluent + { + $columns = []; + + // If the given "index" is actually an array of columns, the developer means + // to drop an index merely by specifying the columns involved without the + // conventional name, so we will build the index name from the columns. + if (is_array($index)) { + $index = $this->createIndexName($type, $columns = $index); + } + + return $this->indexCommand($command, $columns, $index); + } + + /** + * Create a default index name for the table. + */ + protected function createIndexName(string $type, array $columns): string + { + $table = $this->table; + + if ($this->connection->getConfig('prefix_indexes')) { + $table = str_contains($this->table, '.') + ? substr_replace($this->table, '.' . $this->connection->getTablePrefix(), strrpos($this->table, '.'), 1) + : $this->connection->getTablePrefix() . $this->table; + } + + $index = strtolower($table . '_' . implode('_', $columns) . '_' . $type); + + return str_replace(['-', '.'], '_', $index); + } + + /** + * Add a new column to the blueprint. + */ + public function addColumn(string $type, string $name, array $parameters = []): ColumnDefinition + { + return $this->addColumnDefinition(new ColumnDefinition( + array_merge(compact('type', 'name'), $parameters) + )); + } + + /** + * Add a new column definition to the blueprint. + * + * @template TColumnDefinition of \Hypervel\Database\Schema\ColumnDefinition + * + * @param TColumnDefinition $definition + * @return TColumnDefinition + */ + protected function addColumnDefinition(ColumnDefinition $definition): ColumnDefinition + { + $this->columns[] = $definition; + + if (! $this->creating()) { + $this->commands[] = $definition; + } + + if ($this->after) { + $definition->after($this->after); + + // @phpstan-ignore property.notFound (name is a Fluent attribute set when column is created) + $this->after = $definition->name; + } + + return $definition; + } + + /** + * Add the columns from the callback after the given column. + */ + public function after(string $column, Closure $callback): void + { + $this->after = $column; + + $callback($this); + + $this->after = null; + } + + /** + * Remove a column from the schema blueprint. + */ + public function removeColumn(string $name): static + { + $this->columns = array_values(array_filter($this->columns, function ($c) use ($name) { + return $c['name'] != $name; + })); + + $this->commands = array_values(array_filter($this->commands, function ($c) use ($name) { + return ! $c instanceof ColumnDefinition || $c['name'] != $name; + })); + + return $this; + } + + /** + * Add a new command to the blueprint. + */ + protected function addCommand(string $name, array $parameters = []): Fluent + { + $this->commands[] = $command = $this->createCommand($name, $parameters); + + return $command; + } + + /** + * Create a new Fluent command. + */ + protected function createCommand(string $name, array $parameters = []): Fluent + { + return new Fluent(array_merge(compact('name'), $parameters)); + } + + /** + * Get the table the blueprint describes. + */ + public function getTable(): string + { + return $this->table; + } + + /** + * Get the table prefix. + * + * @deprecated Use DB::getTablePrefix() + */ + public function getPrefix(): string + { + return $this->connection->getTablePrefix(); + } + + /** + * Get the columns on the blueprint. + * + * @return \Hypervel\Database\Schema\ColumnDefinition[] + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Get the commands on the blueprint. + * + * @return \Hypervel\Support\Fluent[] + */ + public function getCommands(): array + { + return $this->commands; + } + + /** + * Determine if the blueprint has state. + */ + private function hasState(): bool + { + return ! is_null($this->state); + } + + /** + * Get the state of the blueprint. + */ + public function getState(): ?BlueprintState + { + return $this->state; + } + + /** + * Get the columns on the blueprint that should be added. + * + * @return \Hypervel\Database\Schema\ColumnDefinition[] + */ + public function getAddedColumns(): array + { + return array_filter($this->columns, function ($column) { + return ! $column->change; + }); + } + + /** + * Get the columns on the blueprint that should be changed. + * + * @deprecated will be removed in a future Laravel version + * + * @return \Hypervel\Database\Schema\ColumnDefinition[] + */ + public function getChangedColumns(): array + { + return array_filter($this->columns, function ($column) { + return (bool) $column->change; + }); + } + + /** + * Get the default time precision. + */ + protected function defaultTimePrecision(): ?int + { + return $this->connection->getSchemaBuilder()::$defaultTimePrecision; + } +} diff --git a/src/database/src/Schema/BlueprintState.php b/src/database/src/Schema/BlueprintState.php new file mode 100644 index 000000000..9ab9d8abc --- /dev/null +++ b/src/database/src/Schema/BlueprintState.php @@ -0,0 +1,227 @@ +blueprint = $blueprint; + $this->connection = $connection; + + $schema = $connection->getSchemaBuilder(); + $table = $blueprint->getTable(); + + $this->columns = (new Collection($schema->getColumns($table)))->map(fn ($column) => new ColumnDefinition([ + 'name' => $column['name'], + 'type' => $column['type_name'], + 'full_type_definition' => $column['type'], + 'nullable' => $column['nullable'], + 'default' => is_null($column['default']) ? null : new Expression(Str::wrap($column['default'], '(', ')')), + 'autoIncrement' => $column['auto_increment'], + 'collation' => $column['collation'], + 'comment' => $column['comment'], + 'virtualAs' => ! is_null($column['generation']) && $column['generation']['type'] === 'virtual' + ? $column['generation']['expression'] + : null, + 'storedAs' => ! is_null($column['generation']) && $column['generation']['type'] === 'stored' + ? $column['generation']['expression'] + : null, + ]))->all(); + + [$primary, $indexes] = (new Collection($schema->getIndexes($table)))->map(fn ($index) => new IndexDefinition([ + 'name' => match (true) { + $index['primary'] => 'primary', + $index['unique'] => 'unique', + default => 'index', + }, + 'index' => $index['name'], + 'columns' => $index['columns'], + ]))->partition(fn ($index) => $index->name === 'primary'); + + $this->indexes = $indexes->all(); + $this->primaryKey = $primary->first(); + + $this->foreignKeys = (new Collection($schema->getForeignKeys($table)))->map(fn ($foreignKey) => new ForeignKeyDefinition([ + 'columns' => $foreignKey['columns'], + 'on' => new Expression($foreignKey['foreign_table']), + 'references' => $foreignKey['foreign_columns'], + 'onUpdate' => $foreignKey['on_update'], + 'onDelete' => $foreignKey['on_delete'], + ]))->all(); + } + + /** + * Get the primary key. + */ + public function getPrimaryKey(): ?IndexDefinition + { + return $this->primaryKey; + } + + /** + * Get the columns. + * + * @return \Hypervel\Database\Schema\ColumnDefinition[] + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Get the indexes. + * + * @return \Hypervel\Database\Schema\IndexDefinition[] + */ + public function getIndexes(): array + { + return $this->indexes; + } + + /** + * Get the foreign keys. + * + * @return \Hypervel\Database\Schema\ForeignKeyDefinition[] + */ + public function getForeignKeys(): array + { + return $this->foreignKeys; + } + + /** + * Update the blueprint's state. + */ + public function update(Fluent $command): void + { + switch ($command->name) { + case 'alter': + // Already handled... + break; + case 'add': + $this->columns[] = $command->column; + break; + case 'change': + foreach ($this->columns as &$column) { + if ($column->name === $command->column->name) { + $column = $command->column; + break; + } + } + + break; + case 'renameColumn': + foreach ($this->columns as $column) { + if ($column->name === $command->from) { + $column->name = $command->to; + break; + } + } + + if ($this->primaryKey) { + $this->primaryKey->columns = str_replace($command->from, $command->to, $this->primaryKey->columns); + } + + foreach ($this->indexes as $index) { + $index->columns = str_replace($command->from, $command->to, $index->columns); + } + + foreach ($this->foreignKeys as $foreignKey) { + $foreignKey->columns = str_replace($command->from, $command->to, $foreignKey->columns); + } + + break; + case 'dropColumn': + $this->columns = array_values( + array_filter($this->columns, fn ($column) => ! in_array($column->name, $command->columns)) + ); + + break; + case 'primary': + // @phpstan-ignore assign.propertyType (Blueprint commands are Fluent, stored as IndexDefinition) + $this->primaryKey = $command; + break; + case 'unique': + case 'index': + // @phpstan-ignore assign.propertyType (Blueprint commands are Fluent, stored as IndexDefinition) + $this->indexes[] = $command; + break; + case 'renameIndex': + foreach ($this->indexes as $index) { + if ($index->index === $command->from) { + $index->index = $command->to; + break; + } + } + + break; + case 'foreign': + // @phpstan-ignore assign.propertyType (Blueprint commands are Fluent, stored as ForeignKeyDefinition) + $this->foreignKeys[] = $command; + break; + case 'dropPrimary': + $this->primaryKey = null; + break; + case 'dropIndex': + case 'dropUnique': + $this->indexes = array_values( + array_filter($this->indexes, fn ($index) => $index->index !== $command->index) + ); + + break; + case 'dropForeign': + $this->foreignKeys = array_values( + array_filter($this->foreignKeys, fn ($fk) => $fk->columns !== $command->columns) + ); + + break; + } + } +} diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php new file mode 100755 index 000000000..8ab2bdf44 --- /dev/null +++ b/src/database/src/Schema/Builder.php @@ -0,0 +1,640 @@ +connection = $connection; + $this->grammar = $connection->getSchemaGrammar(); + } + + /** + * Set the default string length for migrations. + */ + public static function defaultStringLength(int $length): void + { + static::$defaultStringLength = $length; + } + + /** + * Set the default time precision for migrations. + */ + public static function defaultTimePrecision(?int $precision): void + { + static::$defaultTimePrecision = $precision; + } + + /** + * Set the default morph key type for migrations. + * + * @throws InvalidArgumentException + */ + public static function defaultMorphKeyType(string $type): void + { + if (! in_array($type, ['int', 'uuid', 'ulid'])) { + throw new InvalidArgumentException("Morph key type must be 'int', 'uuid', or 'ulid'."); + } + + static::$defaultMorphKeyType = $type; + } + + /** + * Set the default morph key type for migrations to UUIDs. + */ + public static function morphUsingUuids(): void + { + static::defaultMorphKeyType('uuid'); + } + + /** + * Set the default morph key type for migrations to ULIDs. + */ + public static function morphUsingUlids(): void + { + static::defaultMorphKeyType('ulid'); + } + + /** + * Create a database in the schema. + */ + public function createDatabase(string $name): bool + { + return $this->connection->statement( + $this->grammar->compileCreateDatabase($name) + ); + } + + /** + * Drop a database from the schema if the database exists. + */ + public function dropDatabaseIfExists(string $name): bool + { + return $this->connection->statement( + $this->grammar->compileDropDatabaseIfExists($name) + ); + } + + /** + * Get the schemas that belong to the connection. + * + * @return list + */ + public function getSchemas(): array + { + return $this->connection->getPostProcessor()->processSchemas( + $this->connection->selectFromWriteConnection($this->grammar->compileSchemas()) + ); + } + + /** + * Determine if the given table exists. + */ + public function hasTable(string $table): bool + { + [$schema, $table] = $this->parseSchemaAndTable($table); + + $table = $this->connection->getTablePrefix() . $table; + + if ($sql = $this->grammar->compileTableExists($schema, $table)) { + return (bool) $this->connection->scalar($sql); + } + + foreach ($this->getTables($schema ?? $this->getCurrentSchemaName()) as $value) { + if (strtolower($table) === strtolower($value['name'])) { + return true; + } + } + + return false; + } + + /** + * Determine if the given view exists. + */ + public function hasView(string $view): bool + { + [$schema, $view] = $this->parseSchemaAndTable($view); + + $view = $this->connection->getTablePrefix() . $view; + + foreach ($this->getViews($schema ?? $this->getCurrentSchemaName()) as $value) { + if (strtolower($view) === strtolower($value['name'])) { + return true; + } + } + + return false; + } + + /** + * Get the tables that belong to the connection. + * + * @param null|string|string[] $schema + * @return list + */ + public function getTables(array|string|null $schema = null): array + { + return $this->connection->getPostProcessor()->processTables( + $this->connection->selectFromWriteConnection($this->grammar->compileTables($schema)) + ); + } + + /** + * Get the names of the tables that belong to the connection. + * + * @return list + */ + public function getTableListing(array|string|null $schema = null, bool $schemaQualified = true): array + { + return array_column( + $this->getTables($schema), + $schemaQualified ? 'schema_qualified_name' : 'name' + ); + } + + /** + * Get the views that belong to the connection. + * + * @return list + */ + public function getViews(array|string|null $schema = null): array + { + return $this->connection->getPostProcessor()->processViews( + $this->connection->selectFromWriteConnection($this->grammar->compileViews($schema)) + ); + } + + /** + * Get the user-defined types that belong to the connection. + * + * @return list + */ + public function getTypes(array|string|null $schema = null): array + { + return $this->connection->getPostProcessor()->processTypes( + $this->connection->selectFromWriteConnection($this->grammar->compileTypes($schema)) + ); + } + + /** + * Determine if the given table has a given column. + */ + public function hasColumn(string $table, string $column): bool + { + return in_array( + strtolower($column), + array_map(strtolower(...), $this->getColumnListing($table)) + ); + } + + /** + * Determine if the given table has given columns. + * + * @param array $columns + */ + public function hasColumns(string $table, array $columns): bool + { + $tableColumns = array_map(strtolower(...), $this->getColumnListing($table)); + + foreach ($columns as $column) { + if (! in_array(strtolower($column), $tableColumns)) { + return false; + } + } + + return true; + } + + /** + * Execute a table builder callback if the given table has a given column. + */ + public function whenTableHasColumn(string $table, string $column, Closure $callback): void + { + if ($this->hasColumn($table, $column)) { + $this->table($table, fn (Blueprint $table) => $callback($table)); + } + } + + /** + * Execute a table builder callback if the given table doesn't have a given column. + */ + public function whenTableDoesntHaveColumn(string $table, string $column, Closure $callback): void + { + if (! $this->hasColumn($table, $column)) { + $this->table($table, fn (Blueprint $table) => $callback($table)); + } + } + + /** + * Execute a table builder callback if the given table has a given index. + */ + public function whenTableHasIndex(string $table, array|string $index, Closure $callback, ?string $type = null): void + { + if ($this->hasIndex($table, $index, $type)) { + $this->table($table, fn (Blueprint $table) => $callback($table)); + } + } + + /** + * Execute a table builder callback if the given table doesn't have a given index. + */ + public function whenTableDoesntHaveIndex(string $table, array|string $index, Closure $callback, ?string $type = null): void + { + if (! $this->hasIndex($table, $index, $type)) { + $this->table($table, fn (Blueprint $table) => $callback($table)); + } + } + + /** + * Get the data type for the given column name. + */ + public function getColumnType(string $table, string $column, bool $fullDefinition = false): string + { + $columns = $this->getColumns($table); + + foreach ($columns as $value) { + if (strtolower($value['name']) === strtolower($column)) { + return $fullDefinition ? $value['type'] : $value['type_name']; + } + } + + throw new InvalidArgumentException("There is no column with name '{$column}' on table '{$table}'."); + } + + /** + * Get the column listing for a given table. + * + * @return list + */ + public function getColumnListing(string $table): array + { + return array_column($this->getColumns($table), 'name'); + } + + /** + * Get the columns for a given table. + * + * @return list + */ + public function getColumns(string $table): array + { + [$schema, $table] = $this->parseSchemaAndTable($table); + + $table = $this->connection->getTablePrefix() . $table; + + return $this->connection->getPostProcessor()->processColumns( + $this->connection->selectFromWriteConnection( + $this->grammar->compileColumns($schema, $table) + ) + ); + } + + /** + * Get the indexes for a given table. + * + * @return list, type: string, unique: bool, primary: bool}> + */ + public function getIndexes(string $table): array + { + [$schema, $table] = $this->parseSchemaAndTable($table); + + $table = $this->connection->getTablePrefix() . $table; + + return $this->connection->getPostProcessor()->processIndexes( + $this->connection->selectFromWriteConnection( + $this->grammar->compileIndexes($schema, $table) + ) + ); + } + + /** + * Get the names of the indexes for a given table. + * + * @return list + */ + public function getIndexListing(string $table): array + { + return array_column($this->getIndexes($table), 'name'); + } + + /** + * Determine if the given table has a given index. + */ + public function hasIndex(string $table, array|string $index, ?string $type = null): bool + { + $type = is_null($type) ? $type : strtolower($type); + + foreach ($this->getIndexes($table) as $value) { + $typeMatches = is_null($type) + || ($type === 'primary' && $value['primary']) + || ($type === 'unique' && $value['unique']) + || $type === $value['type']; + + if (($value['name'] === $index || $value['columns'] === $index) && $typeMatches) { + return true; + } + } + + return false; + } + + /** + * Get the foreign keys for a given table. + */ + public function getForeignKeys(string $table): array + { + [$schema, $table] = $this->parseSchemaAndTable($table); + + $table = $this->connection->getTablePrefix() . $table; + + return $this->connection->getPostProcessor()->processForeignKeys( + $this->connection->selectFromWriteConnection( + $this->grammar->compileForeignKeys($schema, $table) + ) + ); + } + + /** + * Modify a table on the schema. + */ + public function table(string $table, Closure $callback): void + { + $this->build($this->createBlueprint($table, $callback)); + } + + /** + * Create a new table on the schema. + */ + public function create(string $table, Closure $callback): void + { + $this->build(tap($this->createBlueprint($table), function ($blueprint) use ($callback) { + $blueprint->create(); + + $callback($blueprint); + })); + } + + /** + * Drop a table from the schema. + */ + public function drop(string $table): void + { + $this->build(tap($this->createBlueprint($table), function ($blueprint) { + $blueprint->drop(); + })); + } + + /** + * Drop a table from the schema if it exists. + */ + public function dropIfExists(string $table): void + { + $this->build(tap($this->createBlueprint($table), function ($blueprint) { + $blueprint->dropIfExists(); + })); + } + + /** + * Drop columns from a table schema. + * + * @param array|string $columns + */ + public function dropColumns(string $table, array|string $columns): void + { + $this->table($table, function (Blueprint $blueprint) use ($columns) { + $blueprint->dropColumn($columns); + }); + } + + /** + * Drop all tables from the database. + * + * @throws LogicException + */ + public function dropAllTables(): void + { + throw new LogicException('This database driver does not support dropping all tables.'); + } + + /** + * Drop all views from the database. + * + * @throws LogicException + */ + public function dropAllViews(): void + { + throw new LogicException('This database driver does not support dropping all views.'); + } + + /** + * Drop all types from the database. + * + * @throws LogicException + */ + public function dropAllTypes(): void + { + throw new LogicException('This database driver does not support dropping all types.'); + } + + /** + * Rename a table on the schema. + */ + public function rename(string $from, string $to): void + { + $this->build(tap($this->createBlueprint($from), function ($blueprint) use ($to) { + $blueprint->rename($to); + })); + } + + /** + * Enable foreign key constraints. + */ + public function enableForeignKeyConstraints(): bool + { + return $this->connection->statement( + $this->grammar->compileEnableForeignKeyConstraints() + ); + } + + /** + * Disable foreign key constraints. + */ + public function disableForeignKeyConstraints(): bool + { + return $this->connection->statement( + $this->grammar->compileDisableForeignKeyConstraints() + ); + } + + /** + * Disable foreign key constraints during the execution of a callback. + */ + public function withoutForeignKeyConstraints(Closure $callback): mixed + { + $this->disableForeignKeyConstraints(); + + try { + return $callback(); + } finally { + $this->enableForeignKeyConstraints(); + } + } + + /** + * Create the vector extension on the schema if it does not exist. + */ + public function ensureVectorExtensionExists(?string $schema = null): void + { + $this->ensureExtensionExists('vector', $schema); + } + + /** + * Create a new extension on the schema if it does not exist. + */ + public function ensureExtensionExists(string $name, ?string $schema = null): void + { + if (! $this->getConnection() instanceof PostgresConnection) { + throw new RuntimeException('Extensions are only supported by Postgres.'); + } + + $name = $this->getConnection()->getSchemaGrammar()->wrap($name); + + $this->getConnection()->statement(match (filled($schema)) { + true => "create extension if not exists {$name} schema {$this->getConnection()->getSchemaGrammar()->wrap($schema)}", + false => "create extension if not exists {$name}", + }); + } + + /** + * Execute the blueprint to build / modify the table. + */ + protected function build(Blueprint $blueprint): void + { + $blueprint->build(); + } + + /** + * Create a new command set with a Closure. + */ + protected function createBlueprint(string $table, ?Closure $callback = null): Blueprint + { + $connection = $this->connection; + + if (isset($this->resolver)) { + return call_user_func($this->resolver, $connection, $table, $callback); + } + + return Container::getInstance()->make(Blueprint::class, compact('connection', 'table', 'callback')); + } + + /** + * Get the names of the current schemas for the connection. + * + * @return null|string[] + */ + public function getCurrentSchemaListing(): ?array + { + return null; + } + + /** + * Get the default schema name for the connection. + */ + public function getCurrentSchemaName(): ?string + { + return $this->getCurrentSchemaListing()[0] ?? null; + } + + /** + * Parse the given database object reference and extract the schema and table. + */ + public function parseSchemaAndTable(string $reference, bool|string|null $withDefaultSchema = null): array + { + $segments = explode('.', $reference); + + if (count($segments) > 2) { + throw new InvalidArgumentException( + "Using three-part references is not supported, you may use `Schema::connection('{$segments[0]}')` instead." + ); + } + + $table = $segments[1] ?? $segments[0]; + + $schema = match (true) { + isset($segments[1]) => $segments[0], + is_string($withDefaultSchema) => $withDefaultSchema, + $withDefaultSchema => $this->getCurrentSchemaName(), + default => null, + }; + + return [$schema, $table]; + } + + /** + * Get the database connection instance. + */ + public function getConnection(): Connection + { + return $this->connection; + } + + /** + * Set the Schema Blueprint resolver callback. + * + * @param Closure(\Hypervel\Database\Connection, string, null|Closure): \Hypervel\Database\Schema\Blueprint $resolver + */ + public function blueprintResolver(Closure $resolver): void + { + $this->resolver = $resolver; + } +} diff --git a/src/database/src/Schema/ColumnDefinition.php b/src/database/src/Schema/ColumnDefinition.php new file mode 100644 index 000000000..cb185f81f --- /dev/null +++ b/src/database/src/Schema/ColumnDefinition.php @@ -0,0 +1,42 @@ +blueprint = $blueprint; + } + + /** + * Create a foreign key constraint on this column referencing the "id" column of the conventionally related table. + */ + public function constrained(?string $table = null, ?string $column = null, ?string $indexName = null): ForeignKeyDefinition + { + $table ??= $this->table; + $column ??= $this->referencesModelColumn ?? 'id'; + + return $this->references($column, $indexName)->on($table ?? (new Stringable($this->name))->beforeLast('_' . $column)->plural()); + } + + /** + * Specify which column this foreign ID references on another table. + */ + public function references(string $column, ?string $indexName = null): ForeignKeyDefinition + { + return $this->blueprint->foreign($this->name, $indexName)->references($column); + } +} diff --git a/src/database/src/Schema/ForeignKeyDefinition.php b/src/database/src/Schema/ForeignKeyDefinition.php new file mode 100644 index 000000000..8299fe060 --- /dev/null +++ b/src/database/src/Schema/ForeignKeyDefinition.php @@ -0,0 +1,83 @@ +onUpdate('cascade'); + } + + /** + * Indicate that updates should be restricted. + */ + public function restrictOnUpdate(): self + { + return $this->onUpdate('restrict'); + } + + /** + * Indicate that updates should set the foreign key value to null. + */ + public function nullOnUpdate(): self + { + return $this->onUpdate('set null'); + } + + /** + * Indicate that updates should have "no action". + */ + public function noActionOnUpdate(): self + { + return $this->onUpdate('no action'); + } + + /** + * Indicate that deletes should cascade. + */ + public function cascadeOnDelete(): self + { + return $this->onDelete('cascade'); + } + + /** + * Indicate that deletes should be restricted. + */ + public function restrictOnDelete(): self + { + return $this->onDelete('restrict'); + } + + /** + * Indicate that deletes should set the foreign key value to null. + */ + public function nullOnDelete(): self + { + return $this->onDelete('set null'); + } + + /** + * Indicate that deletes should have "no action". + */ + public function noActionOnDelete(): self + { + return $this->onDelete('no action'); + } +} diff --git a/src/database/src/Schema/Grammars/Grammar.php b/src/database/src/Schema/Grammars/Grammar.php new file mode 100755 index 000000000..3a66fccfd --- /dev/null +++ b/src/database/src/Schema/Grammars/Grammar.php @@ -0,0 +1,430 @@ +wrapValue($name), + ); + } + + /** + * Compile a drop database if exists command. + */ + public function compileDropDatabaseIfExists(string $name): string + { + return sprintf( + 'drop database if exists %s', + $this->wrapValue($name) + ); + } + + /** + * Compile the query to determine the schemas. + */ + public function compileSchemas(): string + { + throw new RuntimeException('This database driver does not support retrieving schemas.'); + } + + /** + * Compile the query to determine if the given table exists. + */ + public function compileTableExists(?string $schema, string $table): ?string + { + return null; + } + + /** + * Compile the query to determine the tables. + * + * @param null|string|string[] $schema + */ + public function compileTables(string|array|null $schema): string + { + throw new RuntimeException('This database driver does not support retrieving tables.'); + } + + /** + * Compile the query to determine the views. + * + * @param null|string|string[] $schema + */ + public function compileViews(string|array|null $schema): string + { + throw new RuntimeException('This database driver does not support retrieving views.'); + } + + /** + * Compile the query to determine the user-defined types. + * + * @param null|string|string[] $schema + */ + public function compileTypes(string|array|null $schema): string + { + throw new RuntimeException('This database driver does not support retrieving user-defined types.'); + } + + /** + * Compile the query to determine the columns. + */ + public function compileColumns(?string $schema, string $table): string + { + throw new RuntimeException('This database driver does not support retrieving columns.'); + } + + /** + * Compile the query to determine the indexes. + */ + public function compileIndexes(?string $schema, string $table): string + { + throw new RuntimeException('This database driver does not support retrieving indexes.'); + } + + /** + * Compile a vector index key command. + */ + public function compileVectorIndex(Blueprint $blueprint, Fluent $command): string + { + throw new RuntimeException('The database driver in use does not support vector indexes.'); + } + + /** + * Compile the query to determine the foreign keys. + */ + public function compileForeignKeys(?string $schema, string $table): string + { + throw new RuntimeException('This database driver does not support retrieving foreign keys.'); + } + + /** + * Compile the command to enable foreign key constraints. + */ + public function compileEnableForeignKeyConstraints(): string + { + throw new RuntimeException('This database driver does not support enabling foreign key constraints.'); + } + + /** + * Compile the command to disable foreign key constraints. + */ + public function compileDisableForeignKeyConstraints(): string + { + throw new RuntimeException('This database driver does not support disabling foreign key constraints.'); + } + + /** + * Compile a rename column command. + * + * @return list|string + */ + public function compileRenameColumn(Blueprint $blueprint, Fluent $command): array|string + { + return sprintf( + 'alter table %s rename column %s to %s', + $this->wrapTable($blueprint), + $this->wrap($command->from), + $this->wrap($command->to) + ); + } + + /** + * Compile a change column command into a series of SQL statements. + * + * @return list|string + */ + public function compileChange(Blueprint $blueprint, Fluent $command): array|string + { + throw new RuntimeException('This database driver does not support modifying columns.'); + } + + /** + * Compile a fulltext index key command. + */ + public function compileFulltext(Blueprint $blueprint, Fluent $command): string + { + throw new RuntimeException('This database driver does not support fulltext index creation.'); + } + + /** + * Compile a drop fulltext index command. + */ + public function compileDropFullText(Blueprint $blueprint, Fluent $command): string + { + throw new RuntimeException('This database driver does not support fulltext index removal.'); + } + + /** + * Compile a foreign key command. + */ + public function compileForeign(Blueprint $blueprint, Fluent $command): ?string + { + // We need to prepare several of the elements of the foreign key definition + // before we can create the SQL, such as wrapping the tables and convert + // an array of columns to comma-delimited strings for the SQL queries. + $sql = sprintf( + 'alter table %s add constraint %s ', + $this->wrapTable($blueprint), + $this->wrap($command->index) + ); + + // Once we have the initial portion of the SQL statement we will add on the + // key name, table name, and referenced columns. These will complete the + // main portion of the SQL statement and this SQL will almost be done. + $sql .= sprintf( + 'foreign key (%s) references %s (%s)', + $this->columnize($command->columns), + $this->wrapTable($command->on), + $this->columnize((array) $command->references) + ); + + // Once we have the basic foreign key creation statement constructed we can + // build out the syntax for what should happen on an update or delete of + // the affected columns, which will get something like "cascade", etc. + if (! is_null($command->onDelete)) { + $sql .= " on delete {$command->onDelete}"; + } + + if (! is_null($command->onUpdate)) { + $sql .= " on update {$command->onUpdate}"; + } + + return $sql; + } + + /** + * Compile a drop foreign key command. + */ + public function compileDropForeign(Blueprint $blueprint, Fluent $command): array|string|null + { + throw new RuntimeException('This database driver does not support dropping foreign keys.'); + } + + /** + * Compile the blueprint's added column definitions. + * + * @return string[] + */ + protected function getColumns(Blueprint $blueprint): array + { + $columns = []; + + foreach ($blueprint->getAddedColumns() as $column) { + $columns[] = $this->getColumn($blueprint, $column); + } + + return $columns; + } + + /** + * Compile the column definition. + * + * @param \Hypervel\Database\Schema\ColumnDefinition $column + */ + protected function getColumn(Blueprint $blueprint, Fluent $column): string + { + // Each of the column types has their own compiler functions, which are tasked + // with turning the column definition into its SQL format for this platform + // used by the connection. The column's modifiers are compiled and added. + $sql = $this->wrap($column) . ' ' . $this->getType($column); + + return $this->addModifiers($sql, $blueprint, $column); + } + + /** + * Get the SQL for the column data type. + */ + protected function getType(Fluent $column): string + { + return $this->{'type' . ucfirst($column->type)}($column); + } + + /** + * Create the column definition for a generated, computed column type. + */ + protected function typeComputed(Fluent $column): void + { + throw new RuntimeException('This database driver does not support the computed type.'); + } + + /** + * Create the column definition for a vector type. + */ + protected function typeVector(Fluent $column): string + { + throw new RuntimeException('This database driver does not support the vector type.'); + } + + /** + * Create the column definition for a raw column type. + */ + protected function typeRaw(Fluent $column): string + { + return $column->offsetGet('definition'); + } + + /** + * Add the column modifiers to the definition. + */ + protected function addModifiers(string $sql, Blueprint $blueprint, Fluent $column): string + { + foreach ($this->modifiers as $modifier) { + if (method_exists($this, $method = "modify{$modifier}")) { + $sql .= $this->{$method}($blueprint, $column); + } + } + + return $sql; + } + + /** + * Get the command with a given name if it exists on the blueprint. + */ + protected function getCommandByName(Blueprint $blueprint, string $name): ?Fluent + { + $commands = $this->getCommandsByName($blueprint, $name); + + if (count($commands) > 0) { + return Arr::first($commands); + } + + return null; + } + + /** + * Get all of the commands with a given name. + * + * @return Fluent[] + */ + protected function getCommandsByName(Blueprint $blueprint, string $name): array + { + return array_filter($blueprint->getCommands(), function ($value) use ($name) { + return $value->name == $name; + }); + } + + /** + * Determine if a command with a given name exists on the blueprint. + */ + protected function hasCommand(Blueprint $blueprint, string $name): bool + { + foreach ($blueprint->getCommands() as $command) { + if ($command->name === $name) { + return true; + } + } + + return false; + } + + /** + * Add a prefix to an array of values. + * + * @param string[] $values + * @return string[] + */ + public function prefixArray(string $prefix, array $values): array + { + return array_map(function ($value) use ($prefix) { + return $prefix . ' ' . $value; + }, $values); + } + + /** + * Wrap a table in keyword identifiers. + */ + public function wrapTable(Blueprint|Expression|string $table, ?string $prefix = null): string + { + return parent::wrapTable( + $table instanceof Blueprint ? $table->getTable() : $table, + $prefix + ); + } + + /** + * Wrap a value in keyword identifiers. + */ + public function wrap(Fluent|Expression|string $value): string + { + return parent::wrap( + $value instanceof Fluent ? $value->name : $value, + ); + } + + /** + * Format a value so that it can be used in "default" clauses. + */ + protected function getDefaultValue(mixed $value): string|int|float + { + if ($value instanceof Expression) { + return $this->getValue($value); + } + + if ($value instanceof UnitEnum) { + return "'" . str_replace("'", "''", enum_value($value)) . "'"; + } + + return is_bool($value) + ? "'" . (int) $value . "'" + : "'" . str_replace("'", "''", (string) $value) . "'"; + } + + /** + * Get the fluent commands for the grammar. + * + * @return string[] + */ + public function getFluentCommands(): array + { + return $this->fluentCommands; + } + + /** + * Check if this Grammar supports schema changes wrapped in a transaction. + */ + public function supportsSchemaTransactions(): bool + { + return $this->transactions; + } +} diff --git a/src/database/src/Schema/Grammars/MariaDbGrammar.php b/src/database/src/Schema/Grammars/MariaDbGrammar.php new file mode 100755 index 000000000..0817a1a2b --- /dev/null +++ b/src/database/src/Schema/Grammars/MariaDbGrammar.php @@ -0,0 +1,62 @@ +connection->getServerVersion(), '10.5.2', '<')) { + return $this->compileLegacyRenameColumn($blueprint, $command); + } + + return parent::compileRenameColumn($blueprint, $command); + } + + /** + * Create the column definition for a uuid type. + */ + protected function typeUuid(Fluent $column): string + { + if (version_compare($this->connection->getServerVersion(), '10.7.0', '<')) { + return 'char(36)'; + } + + return 'uuid'; + } + + /** + * Create the column definition for a spatial Geometry type. + */ + protected function typeGeometry(Fluent $column): string + { + $subtype = $column->subtype ? strtolower($column->subtype) : null; + + if (! in_array($subtype, ['point', 'linestring', 'polygon', 'geometrycollection', 'multipoint', 'multilinestring', 'multipolygon'])) { + $subtype = null; + } + + return sprintf( + '%s%s', + $subtype ?? 'geometry', + $column->srid ? ' ref_system_id=' . $column->srid : '' + ); + } + + /** + * Wrap the given JSON selector. + */ + protected function wrapJsonSelector(string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_value(' . $field . $path . ')'; + } +} diff --git a/src/database/src/Schema/Grammars/MySqlGrammar.php b/src/database/src/Schema/Grammars/MySqlGrammar.php new file mode 100755 index 000000000..43e152ea8 --- /dev/null +++ b/src/database/src/Schema/Grammars/MySqlGrammar.php @@ -0,0 +1,1166 @@ +connection->getConfig('charset')) { + $sql .= sprintf(' default character set %s', $this->wrapValue($charset)); + } + + if ($collation = $this->connection->getConfig('collation')) { + $sql .= sprintf(' default collate %s', $this->wrapValue($collation)); + } + + return $sql; + } + + /** + * Compile the query to determine the schemas. + */ + public function compileSchemas(): string + { + return 'select schema_name as name, schema_name = schema() as `default` from information_schema.schemata where ' + . $this->compileSchemaWhereClause(null, 'schema_name') + . ' order by schema_name'; + } + + /** + * Compile the query to determine if the given table exists. + */ + public function compileTableExists(?string $schema, string $table): string + { + return sprintf( + 'select exists (select 1 from information_schema.tables where ' + . "table_schema = %s and table_name = %s and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`", + $schema ? $this->quoteString($schema) : 'schema()', + $this->quoteString($table) + ); + } + + /** + * Compile the query to determine the tables. + */ + public function compileTables(string|array|null $schema): string + { + return sprintf( + 'select table_name as `name`, table_schema as `schema`, (data_length + index_length) as `size`, ' + . 'table_comment as `comment`, engine as `engine`, table_collation as `collation` ' + . "from information_schema.tables where table_type in ('BASE TABLE', 'SYSTEM VERSIONED') and " + . $this->compileSchemaWhereClause($schema, 'table_schema') + . ' order by table_schema, table_name', + $this->quoteString($schema) + ); + } + + /** + * Compile the query to determine the views. + */ + public function compileViews(string|array|null $schema): string + { + return 'select table_name as `name`, table_schema as `schema`, view_definition as `definition` ' + . 'from information_schema.views where ' + . $this->compileSchemaWhereClause($schema, 'table_schema') + . ' order by table_schema, table_name'; + } + + /** + * Compile the query to compare the schema. + */ + protected function compileSchemaWhereClause(string|array|null $schema, string $column): string + { + return $column . (match (true) { + ! empty($schema) && is_array($schema) => ' in (' . $this->quoteString($schema) . ')', + ! empty($schema) => ' = ' . $this->quoteString($schema), + default => " not in ('information_schema', 'mysql', 'ndbinfo', 'performance_schema', 'sys')", + }); + } + + /** + * Compile the query to determine the columns. + */ + public function compileColumns(?string $schema, string $table): string + { + return sprintf( + 'select column_name as `name`, data_type as `type_name`, column_type as `type`, ' + . 'collation_name as `collation`, is_nullable as `nullable`, ' + . 'column_default as `default`, column_comment as `comment`, ' + . 'generation_expression as `expression`, extra as `extra` ' + . 'from information_schema.columns where table_schema = %s and table_name = %s ' + . 'order by ordinal_position asc', + $schema ? $this->quoteString($schema) : 'schema()', + $this->quoteString($table) + ); + } + + /** + * Compile the query to determine the indexes. + */ + public function compileIndexes(?string $schema, string $table): string + { + return sprintf( + 'select index_name as `name`, group_concat(column_name order by seq_in_index) as `columns`, ' + . 'index_type as `type`, not non_unique as `unique` ' + . 'from information_schema.statistics where table_schema = %s and table_name = %s ' + . 'group by index_name, index_type, non_unique', + $schema ? $this->quoteString($schema) : 'schema()', + $this->quoteString($table) + ); + } + + /** + * Compile the query to determine the foreign keys. + */ + public function compileForeignKeys(?string $schema, string $table): string + { + return sprintf( + 'select kc.constraint_name as `name`, ' + . 'group_concat(kc.column_name order by kc.ordinal_position) as `columns`, ' + . 'kc.referenced_table_schema as `foreign_schema`, ' + . 'kc.referenced_table_name as `foreign_table`, ' + . 'group_concat(kc.referenced_column_name order by kc.ordinal_position) as `foreign_columns`, ' + . 'rc.update_rule as `on_update`, ' + . 'rc.delete_rule as `on_delete` ' + . 'from information_schema.key_column_usage kc join information_schema.referential_constraints rc ' + . 'on kc.constraint_schema = rc.constraint_schema and kc.constraint_name = rc.constraint_name ' + . 'where kc.table_schema = %s and kc.table_name = %s and kc.referenced_table_name is not null ' + . 'group by kc.constraint_name, kc.referenced_table_schema, kc.referenced_table_name, rc.update_rule, rc.delete_rule', + $schema ? $this->quoteString($schema) : 'schema()', + $this->quoteString($table) + ); + } + + /** + * Compile a create table command. + */ + public function compileCreate(Blueprint $blueprint, Fluent $command): string + { + $sql = $this->compileCreateTable( + $blueprint, + $command + ); + + // Once we have the primary SQL, we can add the encoding option to the SQL for + // the table. Then, we can check if a storage engine has been supplied for + // the table. If so, we will add the engine declaration to the SQL query. + $sql = $this->compileCreateEncoding( + $sql, + $blueprint + ); + + // Finally, we will append the engine configuration onto this SQL statement as + // the final thing we do before returning this finished SQL. Once this gets + // added the query will be ready to execute against the real connections. + return $this->compileCreateEngine($sql, $blueprint); + } + + /** + * Create the main create table clause. + */ + protected function compileCreateTable(Blueprint $blueprint, Fluent $command): string + { + $tableStructure = $this->getColumns($blueprint); + + if ($primaryKey = $this->getCommandByName($blueprint, 'primary')) { + $tableStructure[] = sprintf( + 'primary key %s(%s)', + $primaryKey->algorithm ? 'using ' . $primaryKey->algorithm : '', + $this->columnize($primaryKey->columns) + ); + + $primaryKey->shouldBeSkipped = true; + } + + return sprintf( + '%s table %s (%s)', + $blueprint->temporary ? 'create temporary' : 'create', + $this->wrapTable($blueprint), + implode(', ', $tableStructure) + ); + } + + /** + * Append the character set specifications to a command. + */ + protected function compileCreateEncoding(string $sql, Blueprint $blueprint): string + { + // First we will set the character set if one has been set on either the create + // blueprint itself or on the root configuration for the connection that the + // table is being created on. We will add these to the create table query. + if (isset($blueprint->charset)) { + $sql .= ' default character set ' . $blueprint->charset; + } elseif (! is_null($charset = $this->connection->getConfig('charset'))) { + $sql .= ' default character set ' . $charset; + } + + // Next we will add the collation to the create table statement if one has been + // added to either this create table blueprint or the configuration for this + // connection that the query is targeting. We'll add it to this SQL query. + if (isset($blueprint->collation)) { + $sql .= " collate '{$blueprint->collation}'"; + } elseif (! is_null($collation = $this->connection->getConfig('collation'))) { + $sql .= " collate '{$collation}'"; + } + + return $sql; + } + + /** + * Append the engine specifications to a command. + */ + protected function compileCreateEngine(string $sql, Blueprint $blueprint): string + { + if (isset($blueprint->engine)) { + return $sql . ' engine = ' . $blueprint->engine; + } + if (! is_null($engine = $this->connection->getConfig('engine'))) { + return $sql . ' engine = ' . $engine; + } + + return $sql; + } + + /** + * Compile an add column command. + */ + public function compileAdd(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'alter table %s add %s%s%s', + $this->wrapTable($blueprint), + $this->getColumn($blueprint, $command->column), + $command->column->instant ? ', algorithm=instant' : '', + $command->column->lock ? ', lock=' . $command->column->lock : '' + ); + } + + /** + * Compile the auto-incrementing column starting values. + */ + public function compileAutoIncrementStartingValues(Blueprint $blueprint, Fluent $command): ?string + { + if ($command->column->autoIncrement + && $value = $command->column->get('startingValue', $command->column->get('from'))) { + return 'alter table ' . $this->wrapTable($blueprint) . ' auto_increment = ' . $value; + } + + return null; + } + + #[Override] + public function compileRenameColumn(Blueprint $blueprint, Fluent $command): array|string + { + $isMaria = $this->connection->isMaria(); + $version = $this->connection->getServerVersion(); + + if (($isMaria && version_compare($version, '10.5.2', '<')) + || (! $isMaria && version_compare($version, '8.0.3', '<'))) { + return $this->compileLegacyRenameColumn($blueprint, $command); + } + + return parent::compileRenameColumn($blueprint, $command); + } + + /** + * Compile a rename column command for legacy versions of MySQL. + */ + protected function compileLegacyRenameColumn(Blueprint $blueprint, Fluent $command): string + { + $column = (new Collection($this->connection->getSchemaBuilder()->getColumns($blueprint->getTable()))) + ->firstWhere('name', $command->from); + + $modifiers = $this->addModifiers($column['type'], $blueprint, new ColumnDefinition([ + 'change' => true, + 'type' => match ($column['type_name']) { + 'bigint' => 'bigInteger', + 'int' => 'integer', + 'mediumint' => 'mediumInteger', + 'smallint' => 'smallInteger', + 'tinyint' => 'tinyInteger', + default => $column['type_name'], + }, + 'nullable' => $column['nullable'], + 'default' => $column['default'] && (str_starts_with(strtolower($column['default']), 'current_timestamp') || $column['default'] === 'NULL') + ? new Expression($column['default']) + : $column['default'], + 'autoIncrement' => $column['auto_increment'], + 'collation' => $column['collation'], + 'comment' => $column['comment'], + 'virtualAs' => ! is_null($column['generation']) && $column['generation']['type'] === 'virtual' + ? $column['generation']['expression'] + : null, + 'storedAs' => ! is_null($column['generation']) && $column['generation']['type'] === 'stored' + ? $column['generation']['expression'] + : null, + ])); + + return sprintf( + 'alter table %s change %s %s %s', + $this->wrapTable($blueprint), + $this->wrap($command->from), + $this->wrap($command->to), + $modifiers + ); + } + + #[Override] + public function compileChange(Blueprint $blueprint, Fluent $command): array|string + { + $column = $command->column; + + $sql = sprintf( + 'alter table %s %s %s%s %s', + $this->wrapTable($blueprint), + is_null($column->renameTo) ? 'modify' : 'change', + $this->wrap($column), + is_null($column->renameTo) ? '' : ' ' . $this->wrap($column->renameTo), + $this->getType($column) + ); + + $sql = $this->addModifiers($sql, $blueprint, $column); + + if ($column->instant) { + $sql .= ', algorithm=instant'; + } + + if ($column->lock) { + $sql .= ', lock=' . $column->lock; + } + + return $sql; + } + + /** + * Compile a primary key command. + */ + public function compilePrimary(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'alter table %s add primary key %s(%s)%s', + $this->wrapTable($blueprint), + $command->algorithm ? 'using ' . $command->algorithm : '', + $this->columnize($command->columns), + $command->lock ? ', lock=' . $command->lock : '' + ); + } + + /** + * Compile a unique key command. + */ + public function compileUnique(Blueprint $blueprint, Fluent $command): string + { + return $this->compileKey($blueprint, $command, 'unique'); + } + + /** + * Compile a plain index key command. + */ + public function compileIndex(Blueprint $blueprint, Fluent $command): string + { + return $this->compileKey($blueprint, $command, 'index'); + } + + /** + * Compile a fulltext index key command. + */ + public function compileFullText(Blueprint $blueprint, Fluent $command): string + { + return $this->compileKey($blueprint, $command, 'fulltext'); + } + + /** + * Compile a spatial index key command. + */ + public function compileSpatialIndex(Blueprint $blueprint, Fluent $command): string + { + return $this->compileKey($blueprint, $command, 'spatial index'); + } + + /** + * Compile an index creation command. + */ + protected function compileKey(Blueprint $blueprint, Fluent $command, string $type): string + { + return sprintf( + 'alter table %s add %s %s%s(%s)%s', + $this->wrapTable($blueprint), + $type, + $this->wrap($command->index), + $command->algorithm ? ' using ' . $command->algorithm : '', + $this->columnize($command->columns), + $command->lock ? ', lock=' . $command->lock : '' + ); + } + + /** + * Compile a drop table command. + */ + public function compileDrop(Blueprint $blueprint, Fluent $command): string + { + return 'drop table ' . $this->wrapTable($blueprint); + } + + /** + * Compile a drop table (if exists) command. + */ + public function compileDropIfExists(Blueprint $blueprint, Fluent $command): string + { + return 'drop table if exists ' . $this->wrapTable($blueprint); + } + + /** + * Compile a drop column command. + */ + public function compileDropColumn(Blueprint $blueprint, Fluent $command): string + { + $columns = $this->prefixArray('drop', $this->wrapArray($command->columns)); + + $sql = 'alter table ' . $this->wrapTable($blueprint) . ' ' . implode(', ', $columns); + + if ($command->instant) { + $sql .= ', algorithm=instant'; + } + + if ($command->lock) { + $sql .= ', lock=' . $command->lock; + } + + return $sql; + } + + /** + * Compile a drop primary key command. + */ + public function compileDropPrimary(Blueprint $blueprint, Fluent $command): string + { + return 'alter table ' . $this->wrapTable($blueprint) . ' drop primary key'; + } + + /** + * Compile a drop unique key command. + */ + public function compileDropUnique(Blueprint $blueprint, Fluent $command): string + { + $index = $this->wrap($command->index); + + return "alter table {$this->wrapTable($blueprint)} drop index {$index}"; + } + + /** + * Compile a drop index command. + */ + public function compileDropIndex(Blueprint $blueprint, Fluent $command): string + { + $index = $this->wrap($command->index); + + return "alter table {$this->wrapTable($blueprint)} drop index {$index}"; + } + + /** + * Compile a drop fulltext index command. + */ + public function compileDropFullText(Blueprint $blueprint, Fluent $command): string + { + return $this->compileDropIndex($blueprint, $command); + } + + /** + * Compile a drop spatial index command. + */ + public function compileDropSpatialIndex(Blueprint $blueprint, Fluent $command): string + { + return $this->compileDropIndex($blueprint, $command); + } + + /** + * Compile a foreign key command. + */ + public function compileForeign(Blueprint $blueprint, Fluent $command): string + { + $sql = parent::compileForeign($blueprint, $command); + + if ($command->lock) { + $sql .= ', lock=' . $command->lock; + } + + return $sql; + } + + /** + * Compile a drop foreign key command. + */ + public function compileDropForeign(Blueprint $blueprint, Fluent $command): string + { + $index = $this->wrap($command->index); + + return "alter table {$this->wrapTable($blueprint)} drop foreign key {$index}"; + } + + /** + * Compile a rename table command. + */ + public function compileRename(Blueprint $blueprint, Fluent $command): string + { + $from = $this->wrapTable($blueprint); + + return "rename table {$from} to " . $this->wrapTable($command->to); + } + + /** + * Compile a rename index command. + */ + public function compileRenameIndex(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'alter table %s rename index %s to %s', + $this->wrapTable($blueprint), + $this->wrap($command->from), + $this->wrap($command->to) + ); + } + + /** + * Compile the SQL needed to drop all tables. + */ + public function compileDropAllTables(array $tables): string + { + return 'drop table ' . implode(', ', $this->escapeNames($tables)); + } + + /** + * Compile the SQL needed to drop all views. + */ + public function compileDropAllViews(array $views): string + { + return 'drop view ' . implode(', ', $this->escapeNames($views)); + } + + /** + * Compile the command to enable foreign key constraints. + */ + #[Override] + public function compileEnableForeignKeyConstraints(): string + { + return 'SET FOREIGN_KEY_CHECKS=1;'; + } + + /** + * Compile the command to disable foreign key constraints. + */ + #[Override] + public function compileDisableForeignKeyConstraints(): string + { + return 'SET FOREIGN_KEY_CHECKS=0;'; + } + + /** + * Compile a table comment command. + */ + public function compileTableComment(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'alter table %s comment = %s', + $this->wrapTable($blueprint), + "'" . str_replace("'", "''", $command->comment) . "'" + ); + } + + /** + * Quote-escape the given tables, views, or types. + */ + public function escapeNames(array $names): array + { + return array_map( + fn ($name) => (new Collection(explode('.', $name)))->map($this->wrapValue(...))->implode('.'), + $names + ); + } + + /** + * Create the column definition for a char type. + */ + protected function typeChar(Fluent $column): string + { + return "char({$column->length})"; + } + + /** + * Create the column definition for a string type. + */ + protected function typeString(Fluent $column): string + { + return "varchar({$column->length})"; + } + + /** + * Create the column definition for a tiny text type. + */ + protected function typeTinyText(Fluent $column): string + { + return 'tinytext'; + } + + /** + * Create the column definition for a text type. + */ + protected function typeText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a medium text type. + */ + protected function typeMediumText(Fluent $column): string + { + return 'mediumtext'; + } + + /** + * Create the column definition for a long text type. + */ + protected function typeLongText(Fluent $column): string + { + return 'longtext'; + } + + /** + * Create the column definition for a big integer type. + */ + protected function typeBigInteger(Fluent $column): string + { + return 'bigint'; + } + + /** + * Create the column definition for an integer type. + */ + protected function typeInteger(Fluent $column): string + { + return 'int'; + } + + /** + * Create the column definition for a medium integer type. + */ + protected function typeMediumInteger(Fluent $column): string + { + return 'mediumint'; + } + + /** + * Create the column definition for a tiny integer type. + */ + protected function typeTinyInteger(Fluent $column): string + { + return 'tinyint'; + } + + /** + * Create the column definition for a small integer type. + */ + protected function typeSmallInteger(Fluent $column): string + { + return 'smallint'; + } + + /** + * Create the column definition for a float type. + */ + protected function typeFloat(Fluent $column): string + { + if ($column->precision) { + return "float({$column->precision})"; + } + + return 'float'; + } + + /** + * Create the column definition for a double type. + */ + protected function typeDouble(Fluent $column): string + { + return 'double'; + } + + /** + * Create the column definition for a decimal type. + */ + protected function typeDecimal(Fluent $column): string + { + return "decimal({$column->total}, {$column->places})"; + } + + /** + * Create the column definition for a boolean type. + */ + protected function typeBoolean(Fluent $column): string + { + return 'tinyint(1)'; + } + + /** + * Create the column definition for an enumeration type. + */ + protected function typeEnum(Fluent $column): string + { + return sprintf('enum(%s)', $this->quoteString($column->allowed)); + } + + /** + * Create the column definition for a set enumeration type. + */ + protected function typeSet(Fluent $column): string + { + return sprintf('set(%s)', $this->quoteString($column->allowed)); + } + + /** + * Create the column definition for a json type. + */ + protected function typeJson(Fluent $column): string + { + return 'json'; + } + + /** + * Create the column definition for a jsonb type. + */ + protected function typeJsonb(Fluent $column): string + { + return 'json'; + } + + /** + * Create the column definition for a date type. + */ + protected function typeDate(Fluent $column): string + { + $isMaria = $this->connection->isMaria(); + $version = $this->connection->getServerVersion(); + + if ($isMaria || version_compare($version, '8.0.13', '>=')) { + if ($column->useCurrent) { + $column->default(new Expression('(CURDATE())')); + } + } + + return 'date'; + } + + /** + * Create the column definition for a date-time type. + */ + protected function typeDateTime(Fluent $column): string + { + $current = $column->precision ? "CURRENT_TIMESTAMP({$column->precision})" : 'CURRENT_TIMESTAMP'; + + if ($column->useCurrent) { + $column->default(new Expression($current)); + } + + if ($column->useCurrentOnUpdate) { + $column->onUpdate(new Expression($current)); + } + + return $column->precision ? "datetime({$column->precision})" : 'datetime'; + } + + /** + * Create the column definition for a date-time (with time zone) type. + */ + protected function typeDateTimeTz(Fluent $column): string + { + return $this->typeDateTime($column); + } + + /** + * Create the column definition for a time type. + */ + protected function typeTime(Fluent $column): string + { + return $column->precision ? "time({$column->precision})" : 'time'; + } + + /** + * Create the column definition for a time (with time zone) type. + */ + protected function typeTimeTz(Fluent $column): string + { + return $this->typeTime($column); + } + + /** + * Create the column definition for a timestamp type. + */ + protected function typeTimestamp(Fluent $column): string + { + $current = $column->precision ? "CURRENT_TIMESTAMP({$column->precision})" : 'CURRENT_TIMESTAMP'; + + if ($column->useCurrent) { + $column->default(new Expression($current)); + } + + if ($column->useCurrentOnUpdate) { + $column->onUpdate(new Expression($current)); + } + + return $column->precision ? "timestamp({$column->precision})" : 'timestamp'; + } + + /** + * Create the column definition for a timestamp (with time zone) type. + */ + protected function typeTimestampTz(Fluent $column): string + { + return $this->typeTimestamp($column); + } + + /** + * Create the column definition for a year type. + */ + protected function typeYear(Fluent $column): string + { + $isMaria = $this->connection->isMaria(); + $version = $this->connection->getServerVersion(); + + if ($isMaria || version_compare($version, '8.0.13', '>=')) { + if ($column->useCurrent) { + $column->default(new Expression('(YEAR(CURDATE()))')); + } + } + + return 'year'; + } + + /** + * Create the column definition for a binary type. + */ + protected function typeBinary(Fluent $column): string + { + if ($column->length) { + return $column->fixed ? "binary({$column->length})" : "varbinary({$column->length})"; + } + + return 'blob'; + } + + /** + * Create the column definition for a uuid type. + */ + protected function typeUuid(Fluent $column): string + { + return 'char(36)'; + } + + /** + * Create the column definition for an IP address type. + */ + protected function typeIpAddress(Fluent $column): string + { + return 'varchar(45)'; + } + + /** + * Create the column definition for a MAC address type. + */ + protected function typeMacAddress(Fluent $column): string + { + return 'varchar(17)'; + } + + /** + * Create the column definition for a spatial Geometry type. + */ + protected function typeGeometry(Fluent $column): string + { + $subtype = $column->subtype ? strtolower($column->subtype) : null; + + if (! in_array($subtype, ['point', 'linestring', 'polygon', 'geometrycollection', 'multipoint', 'multilinestring', 'multipolygon'])) { + $subtype = null; + } + + return sprintf( + '%s%s', + $subtype ?? 'geometry', + match (true) { + $column->srid && $this->connection->isMaria() => ' ref_system_id=' . $column->srid, + (bool) $column->srid => ' srid ' . $column->srid, + default => '', + } + ); + } + + /** + * Create the column definition for a spatial Geography type. + */ + protected function typeGeography(Fluent $column): string + { + return $this->typeGeometry($column); + } + + /** + * Create the column definition for a generated, computed column type. + * + * @throws RuntimeException + */ + protected function typeComputed(Fluent $column): void + { + throw new RuntimeException('This database driver requires a type, see the virtualAs / storedAs modifiers.'); + } + + /** + * Create the column definition for a vector type. + */ + protected function typeVector(Fluent $column): string + { + return isset($column->dimensions) && $column->dimensions !== '' + ? "vector({$column->dimensions})" + : 'vector'; + } + + /** + * Get the SQL for a generated virtual column modifier. + */ + protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($virtualAs = $column->virtualAsJson)) { + if ($this->isJsonSelector($virtualAs)) { + $virtualAs = $this->wrapJsonSelector($virtualAs); + } + + return " as ({$virtualAs})"; + } + + if (! is_null($virtualAs = $column->virtualAs)) { + return " as ({$this->getValue($virtualAs)})"; + } + + return null; + } + + /** + * Get the SQL for a generated stored column modifier. + */ + protected function modifyStoredAs(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($storedAs = $column->storedAsJson)) { + if ($this->isJsonSelector($storedAs)) { + $storedAs = $this->wrapJsonSelector($storedAs); + } + + return " as ({$storedAs}) stored"; + } + + if (! is_null($storedAs = $column->storedAs)) { + return " as ({$this->getValue($storedAs)}) stored"; + } + + return null; + } + + /** + * Get the SQL for an unsigned column modifier. + */ + protected function modifyUnsigned(Blueprint $blueprint, Fluent $column): ?string + { + if ($column->unsigned) { + return ' unsigned'; + } + + return null; + } + + /** + * Get the SQL for a character set column modifier. + */ + protected function modifyCharset(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->charset)) { + return ' character set ' . $column->charset; + } + + return null; + } + + /** + * Get the SQL for a collation column modifier. + */ + protected function modifyCollate(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->collation)) { + return " collate '{$column->collation}'"; + } + + return null; + } + + /** + * Get the SQL for a nullable column modifier. + */ + protected function modifyNullable(Blueprint $blueprint, Fluent $column): ?string + { + if (is_null($column->virtualAs) + && is_null($column->virtualAsJson) + && is_null($column->storedAs) + && is_null($column->storedAsJson)) { + return $column->nullable ? ' null' : ' not null'; + } + + if ($column->nullable === false) { + return ' not null'; + } + + return null; + } + + /** + * Get the SQL for an invisible column modifier. + */ + protected function modifyInvisible(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->invisible)) { + return ' invisible'; + } + + return null; + } + + /** + * Get the SQL for a default column modifier. + */ + protected function modifyDefault(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->default)) { + return ' default ' . $this->getDefaultValue($column->default); + } + + return null; + } + + /** + * Get the SQL for an "on update" column modifier. + */ + protected function modifyOnUpdate(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->onUpdate)) { + return ' on update ' . $this->getValue($column->onUpdate); + } + + return null; + } + + /** + * Get the SQL for an auto-increment column modifier. + */ + protected function modifyIncrement(Blueprint $blueprint, Fluent $column): ?string + { + if (in_array($column->type, $this->serials) && $column->autoIncrement) { + return $this->hasCommand($blueprint, 'primary') || ($column->change && ! $column->primary) + ? ' auto_increment' + : ' auto_increment primary key'; + } + + return null; + } + + /** + * Get the SQL for a "first" column modifier. + */ + protected function modifyFirst(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->first)) { + return ' first'; + } + + return null; + } + + /** + * Get the SQL for an "after" column modifier. + */ + protected function modifyAfter(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->after)) { + return ' after ' . $this->wrap($column->after); + } + + return null; + } + + /** + * Get the SQL for a "comment" column modifier. + */ + protected function modifyComment(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->comment)) { + return " comment '" . addslashes($column->comment) . "'"; + } + + return null; + } + + /** + * Wrap a single string in keyword identifiers. + */ + protected function wrapValue(string $value): string + { + if ($value !== '*') { + return '`' . str_replace('`', '``', $value) . '`'; + } + + return $value; + } + + /** + * Wrap the given JSON selector. + */ + protected function wrapJsonSelector(string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_unquote(json_extract(' . $field . $path . '))'; + } +} diff --git a/src/database/src/Schema/Grammars/PostgresGrammar.php b/src/database/src/Schema/Grammars/PostgresGrammar.php new file mode 100755 index 000000000..7478d748e --- /dev/null +++ b/src/database/src/Schema/Grammars/PostgresGrammar.php @@ -0,0 +1,1082 @@ +connection->getConfig('charset')) { + $sql .= sprintf(' encoding %s', $this->wrapValue($charset)); + } + + return $sql; + } + + /** + * Compile the query to determine the schemas. + */ + public function compileSchemas(): string + { + return 'select nspname as name, nspname = current_schema() as "default" from pg_namespace where ' + . $this->compileSchemaWhereClause(null, 'nspname') + . ' order by nspname'; + } + + /** + * Compile the query to determine if the given table exists. + */ + public function compileTableExists(?string $schema, string $table): ?string + { + return sprintf( + 'select exists (select 1 from pg_class c, pg_namespace n where ' + . "n.nspname = %s and c.relname = %s and c.relkind in ('r', 'p') and n.oid = c.relnamespace)", + $schema ? $this->quoteString($schema) : 'current_schema()', + $this->quoteString($table) + ); + } + + /** + * Compile the query to determine the tables. + * + * @param null|string|string[] $schema + */ + public function compileTables(string|array|null $schema): string + { + return 'select c.relname as name, n.nspname as schema, pg_total_relation_size(c.oid) as size, ' + . "obj_description(c.oid, 'pg_class') as comment from pg_class c, pg_namespace n " + . "where c.relkind in ('r', 'p') and n.oid = c.relnamespace and " + . $this->compileSchemaWhereClause($schema, 'n.nspname') + . ' order by n.nspname, c.relname'; + } + + /** + * Compile the query to determine the views. + */ + public function compileViews(string|array|null $schema): string + { + return 'select viewname as name, schemaname as schema, definition from pg_views where ' + . $this->compileSchemaWhereClause($schema, 'schemaname') + . ' order by schemaname, viewname'; + } + + /** + * Compile the query to determine the user-defined types. + */ + public function compileTypes(string|array|null $schema): string + { + return 'select t.typname as name, n.nspname as schema, t.typtype as type, t.typcategory as category, ' + . "((t.typinput = 'array_in'::regproc and t.typoutput = 'array_out'::regproc) or t.typtype = 'm') as implicit " + . 'from pg_type t join pg_namespace n on n.oid = t.typnamespace ' + . 'left join pg_class c on c.oid = t.typrelid ' + . 'left join pg_type el on el.oid = t.typelem ' + . 'left join pg_class ce on ce.oid = el.typrelid ' + . "where ((t.typrelid = 0 and (ce.relkind = 'c' or ce.relkind is null)) or c.relkind = 'c') " + . "and not exists (select 1 from pg_depend d where d.objid in (t.oid, t.typelem) and d.deptype = 'e') and " + . $this->compileSchemaWhereClause($schema, 'n.nspname'); + } + + /** + * Compile the query to compare the schema. + */ + protected function compileSchemaWhereClause(string|array|null $schema, string $column): string + { + return $column . (match (true) { + ! empty($schema) && is_array($schema) => ' in (' . $this->quoteString($schema) . ')', + ! empty($schema) => ' = ' . $this->quoteString($schema), + default => " <> 'information_schema' and {$column} not like 'pg\\_%'", + }); + } + + /** + * Compile the query to determine the columns. + */ + public function compileColumns(?string $schema, string $table): string + { + return sprintf( + 'select a.attname as name, t.typname as type_name, format_type(a.atttypid, a.atttypmod) as type, ' + . '(select tc.collcollate from pg_catalog.pg_collation tc where tc.oid = a.attcollation) as collation, ' + . 'not a.attnotnull as nullable, ' + . '(select pg_get_expr(adbin, adrelid) from pg_attrdef where c.oid = pg_attrdef.adrelid and pg_attrdef.adnum = a.attnum) as default, ' + . (version_compare($this->connection->getServerVersion(), '12.0', '<') ? "'' as generated, " : 'a.attgenerated as generated, ') + . 'col_description(c.oid, a.attnum) as comment ' + . 'from pg_attribute a, pg_class c, pg_type t, pg_namespace n ' + . 'where c.relname = %s and n.nspname = %s and a.attnum > 0 and a.attrelid = c.oid and a.atttypid = t.oid and n.oid = c.relnamespace ' + . 'order by a.attnum', + $this->quoteString($table), + $schema ? $this->quoteString($schema) : 'current_schema()' + ); + } + + /** + * Compile the query to determine the indexes. + */ + public function compileIndexes(?string $schema, string $table): string + { + return sprintf( + "select ic.relname as name, string_agg(a.attname, ',' order by indseq.ord) as columns, " + . 'am.amname as "type", i.indisunique as "unique", i.indisprimary as "primary" ' + . 'from pg_index i ' + . 'join pg_class tc on tc.oid = i.indrelid ' + . 'join pg_namespace tn on tn.oid = tc.relnamespace ' + . 'join pg_class ic on ic.oid = i.indexrelid ' + . 'join pg_am am on am.oid = ic.relam ' + . 'join lateral unnest(i.indkey) with ordinality as indseq(num, ord) on true ' + . 'left join pg_attribute a on a.attrelid = i.indrelid and a.attnum = indseq.num ' + . 'where tc.relname = %s and tn.nspname = %s ' + . 'group by ic.relname, am.amname, i.indisunique, i.indisprimary', + $this->quoteString($table), + $schema ? $this->quoteString($schema) : 'current_schema()' + ); + } + + /** + * Compile the query to determine the foreign keys. + */ + public function compileForeignKeys(?string $schema, string $table): string + { + return sprintf( + 'select c.conname as name, ' + . "string_agg(la.attname, ',' order by conseq.ord) as columns, " + . 'fn.nspname as foreign_schema, fc.relname as foreign_table, ' + . "string_agg(fa.attname, ',' order by conseq.ord) as foreign_columns, " + . 'c.confupdtype as on_update, c.confdeltype as on_delete ' + . 'from pg_constraint c ' + . 'join pg_class tc on c.conrelid = tc.oid ' + . 'join pg_namespace tn on tn.oid = tc.relnamespace ' + . 'join pg_class fc on c.confrelid = fc.oid ' + . 'join pg_namespace fn on fn.oid = fc.relnamespace ' + . 'join lateral unnest(c.conkey) with ordinality as conseq(num, ord) on true ' + . 'join pg_attribute la on la.attrelid = c.conrelid and la.attnum = conseq.num ' + . 'join pg_attribute fa on fa.attrelid = c.confrelid and fa.attnum = c.confkey[conseq.ord] ' + . "where c.contype = 'f' and tc.relname = %s and tn.nspname = %s " + . 'group by c.conname, fn.nspname, fc.relname, c.confupdtype, c.confdeltype', + $this->quoteString($table), + $schema ? $this->quoteString($schema) : 'current_schema()' + ); + } + + /** + * Compile a create table command. + */ + public function compileCreate(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + '%s table %s (%s)', + $blueprint->temporary ? 'create temporary' : 'create', + $this->wrapTable($blueprint), + implode(', ', $this->getColumns($blueprint)) + ); + } + + /** + * Compile a column addition command. + */ + public function compileAdd(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'alter table %s add column %s', + $this->wrapTable($blueprint), + $this->getColumn($blueprint, $command->column) + ); + } + + /** + * Compile the auto-incrementing column starting values. + */ + public function compileAutoIncrementStartingValues(Blueprint $blueprint, Fluent $command): ?string + { + if ($command->column->autoIncrement + && $value = $command->column->get('startingValue', $command->column->get('from'))) { + [$schema, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + + $table = ($schema ? $schema . '.' : '') . $this->connection->getTablePrefix() . $table; + + return 'alter sequence ' . $table . '_' . $command->column->name . '_seq restart with ' . $value; + } + + return null; + } + + #[Override] + public function compileChange(Blueprint $blueprint, Fluent $command): array|string + { + $column = $command->column; + + $changes = ['type ' . $this->getType($column) . $this->modifyCollate($blueprint, $column)]; + + foreach ($this->modifiers as $modifier) { + if ($modifier === 'Collate') { + continue; + } + + if (method_exists($this, $method = "modify{$modifier}")) { + $constraints = (array) $this->{$method}($blueprint, $column); + + foreach ($constraints as $constraint) { + $changes[] = $constraint; + } + } + } + + return sprintf( + 'alter table %s %s', + $this->wrapTable($blueprint), + implode(', ', $this->prefixArray('alter column ' . $this->wrap($column), $changes)) + ); + } + + /** + * Compile a primary key command. + */ + public function compilePrimary(Blueprint $blueprint, Fluent $command): string + { + $columns = $this->columnize($command->columns); + + return 'alter table ' . $this->wrapTable($blueprint) . " add primary key ({$columns})"; + } + + /** + * Compile a unique key command. + * + * @return string[] + */ + public function compileUnique(Blueprint $blueprint, Fluent $command): array + { + $uniqueStatement = 'unique'; + + if (! is_null($command->nullsNotDistinct)) { + $uniqueStatement .= ' nulls ' . ($command->nullsNotDistinct ? 'not distinct' : 'distinct'); + } + + if ($command->online || $command->algorithm) { + $createIndexSql = sprintf( + 'create unique index %s%s on %s%s (%s)', + $command->online ? 'concurrently ' : '', + $this->wrap($command->index), + $this->wrapTable($blueprint), + $command->algorithm ? ' using ' . $command->algorithm : '', + $this->columnize($command->columns) + ); + + $sql = sprintf( + 'alter table %s add constraint %s unique using index %s', + $this->wrapTable($blueprint), + $this->wrap($command->index), + $this->wrap($command->index) + ); + } else { + $sql = sprintf( + 'alter table %s add constraint %s %s (%s)', + $this->wrapTable($blueprint), + $this->wrap($command->index), + $uniqueStatement, + $this->columnize($command->columns) + ); + } + + if (! is_null($command->deferrable)) { + $sql .= $command->deferrable ? ' deferrable' : ' not deferrable'; + } + + if ($command->deferrable && ! is_null($command->initiallyImmediate)) { + $sql .= $command->initiallyImmediate ? ' initially immediate' : ' initially deferred'; + } + + return isset($createIndexSql) ? [$createIndexSql, $sql] : [$sql]; + } + + /** + * Compile a plain index key command. + */ + public function compileIndex(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'create index %s%s on %s%s (%s)', + $command->online ? 'concurrently ' : '', + $this->wrap($command->index), + $this->wrapTable($blueprint), + $command->algorithm ? ' using ' . $command->algorithm : '', + $this->columnize($command->columns) + ); + } + + /** + * Compile a fulltext index key command. + */ + public function compileFulltext(Blueprint $blueprint, Fluent $command): string + { + $language = $command->language ?: 'english'; + + $columns = array_map(function ($column) use ($language) { + return "to_tsvector({$this->quoteString($language)}, {$this->wrap($column)})"; + }, $command->columns); + + return sprintf( + 'create index %s%s on %s using gin ((%s))', + $command->online ? 'concurrently ' : '', + $this->wrap($command->index), + $this->wrapTable($blueprint), + implode(' || ', $columns) + ); + } + + /** + * Compile a spatial index key command. + */ + public function compileSpatialIndex(Blueprint $blueprint, Fluent $command): string + { + $command->algorithm = 'gist'; + + if (! is_null($command->operatorClass)) { + return $this->compileIndexWithOperatorClass($blueprint, $command); + } + + return $this->compileIndex($blueprint, $command); + } + + /** + * Compile a vector index key command. + */ + public function compileVectorIndex(Blueprint $blueprint, Fluent $command): string + { + return $this->compileIndexWithOperatorClass($blueprint, $command); + } + + /** + * Compile a spatial index with operator class key command. + */ + protected function compileIndexWithOperatorClass(Blueprint $blueprint, Fluent $command): string + { + $columns = $this->columnizeWithOperatorClass($command->columns, $command->operatorClass); + + return sprintf( + 'create index %s%s on %s%s (%s)', + $command->online ? 'concurrently ' : '', + $this->wrap($command->index), + $this->wrapTable($blueprint), + $command->algorithm ? ' using ' . $command->algorithm : '', + $columns + ); + } + + /** + * Convert an array of column names to a delimited string with operator class. + */ + protected function columnizeWithOperatorClass(array $columns, string $operatorClass): string + { + return implode(', ', array_map(function ($column) use ($operatorClass) { + return $this->wrap($column) . ' ' . $operatorClass; + }, $columns)); + } + + /** + * Compile a foreign key command. + */ + public function compileForeign(Blueprint $blueprint, Fluent $command): string + { + $sql = parent::compileForeign($blueprint, $command); + + if (! is_null($command->deferrable)) { + $sql .= $command->deferrable ? ' deferrable' : ' not deferrable'; + } + + if ($command->deferrable && ! is_null($command->initiallyImmediate)) { + $sql .= $command->initiallyImmediate ? ' initially immediate' : ' initially deferred'; + } + + if (! is_null($command->notValid)) { + $sql .= ' not valid'; + } + + return $sql; + } + + /** + * Compile a drop table command. + */ + public function compileDrop(Blueprint $blueprint, Fluent $command): string + { + return 'drop table ' . $this->wrapTable($blueprint); + } + + /** + * Compile a drop table (if exists) command. + */ + public function compileDropIfExists(Blueprint $blueprint, Fluent $command): string + { + return 'drop table if exists ' . $this->wrapTable($blueprint); + } + + /** + * Compile the SQL needed to drop all tables. + */ + public function compileDropAllTables(array $tables): string + { + return 'drop table ' . implode(', ', $this->escapeNames($tables)) . ' cascade'; + } + + /** + * Compile the SQL needed to drop all views. + */ + public function compileDropAllViews(array $views): string + { + return 'drop view ' . implode(', ', $this->escapeNames($views)) . ' cascade'; + } + + /** + * Compile the SQL needed to drop all types. + */ + public function compileDropAllTypes(array $types): string + { + return 'drop type ' . implode(', ', $this->escapeNames($types)) . ' cascade'; + } + + /** + * Compile the SQL needed to drop all domains. + */ + public function compileDropAllDomains(array $domains): string + { + return 'drop domain ' . implode(', ', $this->escapeNames($domains)) . ' cascade'; + } + + /** + * Compile a drop column command. + */ + public function compileDropColumn(Blueprint $blueprint, Fluent $command): string + { + $columns = $this->prefixArray('drop column', $this->wrapArray($command->columns)); + + return 'alter table ' . $this->wrapTable($blueprint) . ' ' . implode(', ', $columns); + } + + /** + * Compile a drop primary key command. + */ + public function compileDropPrimary(Blueprint $blueprint, Fluent $command): string + { + [, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + $index = $this->wrap("{$this->connection->getTablePrefix()}{$table}_pkey"); + + return 'alter table ' . $this->wrapTable($blueprint) . " drop constraint {$index}"; + } + + /** + * Compile a drop unique key command. + */ + public function compileDropUnique(Blueprint $blueprint, Fluent $command): string + { + $index = $this->wrap($command->index); + + return "alter table {$this->wrapTable($blueprint)} drop constraint {$index}"; + } + + /** + * Compile a drop index command. + */ + public function compileDropIndex(Blueprint $blueprint, Fluent $command): string + { + return "drop index {$this->wrap($command->index)}"; + } + + /** + * Compile a drop fulltext index command. + */ + public function compileDropFullText(Blueprint $blueprint, Fluent $command): string + { + return $this->compileDropIndex($blueprint, $command); + } + + /** + * Compile a drop spatial index command. + */ + public function compileDropSpatialIndex(Blueprint $blueprint, Fluent $command): string + { + return $this->compileDropIndex($blueprint, $command); + } + + /** + * Compile a drop foreign key command. + */ + public function compileDropForeign(Blueprint $blueprint, Fluent $command): string + { + $index = $this->wrap($command->index); + + return "alter table {$this->wrapTable($blueprint)} drop constraint {$index}"; + } + + /** + * Compile a rename table command. + */ + public function compileRename(Blueprint $blueprint, Fluent $command): string + { + $from = $this->wrapTable($blueprint); + + return "alter table {$from} rename to " . $this->wrapTable($command->to); + } + + /** + * Compile a rename index command. + */ + public function compileRenameIndex(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'alter index %s rename to %s', + $this->wrap($command->from), + $this->wrap($command->to) + ); + } + + /** + * Compile the command to enable foreign key constraints. + */ + #[Override] + public function compileEnableForeignKeyConstraints(): string + { + return 'SET CONSTRAINTS ALL IMMEDIATE;'; + } + + /** + * Compile the command to disable foreign key constraints. + */ + #[Override] + public function compileDisableForeignKeyConstraints(): string + { + return 'SET CONSTRAINTS ALL DEFERRED;'; + } + + /** + * Compile a comment command. + */ + public function compileComment(Blueprint $blueprint, Fluent $command): ?string + { + if (! is_null($comment = $command->column->comment) || $command->column->change) { + return sprintf( + 'comment on column %s.%s is %s', + $this->wrapTable($blueprint), + $this->wrap($command->column->name), + is_null($comment) ? 'NULL' : "'" . str_replace("'", "''", $comment) . "'" + ); + } + + return null; + } + + /** + * Compile a table comment command. + */ + public function compileTableComment(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'comment on table %s is %s', + $this->wrapTable($blueprint), + "'" . str_replace("'", "''", $command->comment) . "'" + ); + } + + /** + * Quote-escape the given tables, views, or types. + */ + public function escapeNames(array $names): array + { + return array_map( + fn ($name) => (new Collection(explode('.', $name)))->map($this->wrapValue(...))->implode('.'), + $names + ); + } + + /** + * Create the column definition for a char type. + */ + protected function typeChar(Fluent $column): string + { + if ($column->length) { + return "char({$column->length})"; + } + + return 'char'; + } + + /** + * Create the column definition for a string type. + */ + protected function typeString(Fluent $column): string + { + if ($column->length) { + return "varchar({$column->length})"; + } + + return 'varchar'; + } + + /** + * Create the column definition for a tiny text type. + */ + protected function typeTinyText(Fluent $column): string + { + return 'varchar(255)'; + } + + /** + * Create the column definition for a text type. + */ + protected function typeText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a medium text type. + */ + protected function typeMediumText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a long text type. + */ + protected function typeLongText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for an integer type. + */ + protected function typeInteger(Fluent $column): string + { + return $column->autoIncrement && is_null($column->generatedAs) && ! $column->change ? 'serial' : 'integer'; + } + + /** + * Create the column definition for a big integer type. + */ + protected function typeBigInteger(Fluent $column): string + { + return $column->autoIncrement && is_null($column->generatedAs) && ! $column->change ? 'bigserial' : 'bigint'; + } + + /** + * Create the column definition for a medium integer type. + */ + protected function typeMediumInteger(Fluent $column): string + { + return $this->typeInteger($column); + } + + /** + * Create the column definition for a tiny integer type. + */ + protected function typeTinyInteger(Fluent $column): string + { + return $this->typeSmallInteger($column); + } + + /** + * Create the column definition for a small integer type. + */ + protected function typeSmallInteger(Fluent $column): string + { + return $column->autoIncrement && is_null($column->generatedAs) && ! $column->change ? 'smallserial' : 'smallint'; + } + + /** + * Create the column definition for a float type. + */ + protected function typeFloat(Fluent $column): string + { + if ($column->precision) { + return "float({$column->precision})"; + } + + return 'float'; + } + + /** + * Create the column definition for a double type. + */ + protected function typeDouble(Fluent $column): string + { + return 'double precision'; + } + + /** + * Create the column definition for a real type. + */ + protected function typeReal(Fluent $column): string + { + return 'real'; + } + + /** + * Create the column definition for a decimal type. + */ + protected function typeDecimal(Fluent $column): string + { + return "decimal({$column->total}, {$column->places})"; + } + + /** + * Create the column definition for a boolean type. + */ + protected function typeBoolean(Fluent $column): string + { + return 'boolean'; + } + + /** + * Create the column definition for an enumeration type. + */ + protected function typeEnum(Fluent $column): string + { + return sprintf( + 'varchar(255) check ("%s" in (%s))', + $column->name, + $this->quoteString($column->allowed) + ); + } + + /** + * Create the column definition for a json type. + */ + protected function typeJson(Fluent $column): string + { + return 'json'; + } + + /** + * Create the column definition for a jsonb type. + */ + protected function typeJsonb(Fluent $column): string + { + return 'jsonb'; + } + + /** + * Create the column definition for a date type. + */ + protected function typeDate(Fluent $column): string + { + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_DATE')); + } + + return 'date'; + } + + /** + * Create the column definition for a date-time type. + */ + protected function typeDateTime(Fluent $column): string + { + return $this->typeTimestamp($column); + } + + /** + * Create the column definition for a date-time (with time zone) type. + */ + protected function typeDateTimeTz(Fluent $column): string + { + return $this->typeTimestampTz($column); + } + + /** + * Create the column definition for a time type. + */ + protected function typeTime(Fluent $column): string + { + return 'time' . (is_null($column->precision) ? '' : "({$column->precision})") . ' without time zone'; + } + + /** + * Create the column definition for a time (with time zone) type. + */ + protected function typeTimeTz(Fluent $column): string + { + return 'time' . (is_null($column->precision) ? '' : "({$column->precision})") . ' with time zone'; + } + + /** + * Create the column definition for a timestamp type. + */ + protected function typeTimestamp(Fluent $column): string + { + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_TIMESTAMP')); + } + + return 'timestamp' . (is_null($column->precision) ? '' : "({$column->precision})") . ' without time zone'; + } + + /** + * Create the column definition for a timestamp (with time zone) type. + */ + protected function typeTimestampTz(Fluent $column): string + { + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_TIMESTAMP')); + } + + return 'timestamp' . (is_null($column->precision) ? '' : "({$column->precision})") . ' with time zone'; + } + + /** + * Create the column definition for a year type. + */ + protected function typeYear(Fluent $column): string + { + if ($column->useCurrent) { + $column->default(new Expression('EXTRACT(YEAR FROM CURRENT_DATE)')); + } + + return $this->typeInteger($column); + } + + /** + * Create the column definition for a binary type. + */ + protected function typeBinary(Fluent $column): string + { + return 'bytea'; + } + + /** + * Create the column definition for a uuid type. + */ + protected function typeUuid(Fluent $column): string + { + return 'uuid'; + } + + /** + * Create the column definition for an IP address type. + */ + protected function typeIpAddress(Fluent $column): string + { + return 'inet'; + } + + /** + * Create the column definition for a MAC address type. + */ + protected function typeMacAddress(Fluent $column): string + { + return 'macaddr'; + } + + /** + * Create the column definition for a spatial Geometry type. + */ + protected function typeGeometry(Fluent $column): string + { + if ($column->subtype) { + return sprintf( + 'geometry(%s%s)', + strtolower($column->subtype), + $column->srid ? ',' . $column->srid : '' + ); + } + + return 'geometry'; + } + + /** + * Create the column definition for a spatial Geography type. + */ + protected function typeGeography(Fluent $column): string + { + if ($column->subtype) { + return sprintf( + 'geography(%s%s)', + strtolower($column->subtype), + $column->srid ? ',' . $column->srid : '' + ); + } + + return 'geography'; + } + + /** + * Create the column definition for a vector type. + */ + protected function typeVector(Fluent $column): string + { + return isset($column->dimensions) && $column->dimensions !== '' + ? "vector({$column->dimensions})" + : 'vector'; + } + + /** + * Get the SQL for a collation column modifier. + */ + protected function modifyCollate(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->collation)) { + return ' collate ' . $this->wrapValue($column->collation); + } + + return null; + } + + /** + * Get the SQL for a nullable column modifier. + */ + protected function modifyNullable(Blueprint $blueprint, Fluent $column): string + { + if ($column->change) { + return $column->nullable ? 'drop not null' : 'set not null'; + } + + return $column->nullable ? ' null' : ' not null'; + } + + /** + * Get the SQL for a default column modifier. + */ + protected function modifyDefault(Blueprint $blueprint, Fluent $column): ?string + { + if ($column->change) { + if (! $column->autoIncrement || ! is_null($column->generatedAs)) { + return is_null($column->default) ? 'drop default' : 'set default ' . $this->getDefaultValue($column->default); + } + + return null; + } + + if (! is_null($column->default)) { + return ' default ' . $this->getDefaultValue($column->default); + } + + return null; + } + + /** + * Get the SQL for an auto-increment column modifier. + */ + protected function modifyIncrement(Blueprint $blueprint, Fluent $column): ?string + { + if (! $column->change + && ! $this->hasCommand($blueprint, 'primary') + && (in_array($column->type, $this->serials) || ($column->generatedAs !== null)) + && $column->autoIncrement) { + return ' primary key'; + } + + return null; + } + + /** + * Get the SQL for a generated virtual column modifier. + */ + protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column): ?string + { + if ($column->change) { + if (array_key_exists('virtualAs', $column->getAttributes())) { + return is_null($column->virtualAs) + ? 'drop expression if exists' + : throw new LogicException('This database driver does not support modifying generated columns.'); + } + + return null; + } + + if (! is_null($column->virtualAs)) { + return " generated always as ({$this->getValue($column->virtualAs)}) virtual"; + } + + return null; + } + + /** + * Get the SQL for a generated stored column modifier. + */ + protected function modifyStoredAs(Blueprint $blueprint, Fluent $column): ?string + { + if ($column->change) { + if (array_key_exists('storedAs', $column->getAttributes())) { + return is_null($column->storedAs) + ? 'drop expression if exists' + : throw new LogicException('This database driver does not support modifying generated columns.'); + } + + return null; + } + + if (! is_null($column->storedAs)) { + return " generated always as ({$this->getValue($column->storedAs)}) stored"; + } + + return null; + } + + /** + * Get the SQL for an identity column modifier. + * + * @return null|list|string + */ + protected function modifyGeneratedAs(Blueprint $blueprint, Fluent $column): array|string|null + { + $sql = null; + + if (! is_null($column->generatedAs)) { + $sql = sprintf( + ' generated %s as identity%s', + $column->always ? 'always' : 'by default', + ! is_bool($column->generatedAs) && ! empty($column->generatedAs) ? " ({$column->generatedAs})" : '' + ); + } + + if ($column->change) { + $changes = $column->autoIncrement && is_null($sql) ? [] : ['drop identity if exists']; + + if (! is_null($sql)) { + $changes[] = 'add ' . $sql; + } + + return $changes; + } + + return $sql; + } +} diff --git a/src/database/src/Schema/Grammars/SQLiteGrammar.php b/src/database/src/Schema/Grammars/SQLiteGrammar.php new file mode 100644 index 000000000..64ed83263 --- /dev/null +++ b/src/database/src/Schema/Grammars/SQLiteGrammar.php @@ -0,0 +1,990 @@ +connection->getServerVersion(), '3.35', '<')) { + $alterCommands[] = 'dropColumn'; + } + + return $alterCommands; + } + + /** + * Compile the query to determine the SQL text that describes the given object. + */ + public function compileSqlCreateStatement(?string $schema, string $name, string $type = 'table'): string + { + return sprintf( + 'select "sql" from %s.sqlite_master where type = %s and name = %s', + $this->wrapValue($schema ?? 'main'), + $this->quoteString($type), + $this->quoteString($name) + ); + } + + /** + * Compile the query to determine if the dbstat table is available. + */ + public function compileDbstatExists(): string + { + return "select exists (select 1 from pragma_compile_options where compile_options = 'ENABLE_DBSTAT_VTAB') as enabled"; + } + + /** + * Compile the query to determine the schemas. + */ + public function compileSchemas(): string + { + return 'select name, file as path, name = \'main\' as "default" from pragma_database_list order by name'; + } + + /** + * Compile the query to determine if the given table exists. + */ + public function compileTableExists(?string $schema, string $table): string + { + return sprintf( + 'select exists (select 1 from %s.sqlite_master where name = %s and type = \'table\') as "exists"', + $this->wrapValue($schema ?? 'main'), + $this->quoteString($table) + ); + } + + /** + * Compile the query to determine the tables. + * + * @param null|string|string[] $schema + */ + public function compileTables(string|array|null $schema, bool $withSize = false): string + { + return 'select tl.name as name, tl.schema as schema' + . ($withSize ? ', (select sum(s.pgsize) ' + . 'from (select tl.name as name union select il.name as name from pragma_index_list(tl.name, tl.schema) as il) as es ' + . 'join dbstat(tl.schema) as s on s.name = es.name) as size' : '') + . ' from pragma_table_list as tl where' + . (match (true) { + ! empty($schema) && is_array($schema) => ' tl.schema in (' . $this->quoteString($schema) . ') and', + ! empty($schema) => ' tl.schema = ' . $this->quoteString($schema) . ' and', + default => '', + }) + . " tl.type in ('table', 'virtual') and tl.name not like 'sqlite\\_%' escape '\\' " + . 'order by tl.schema, tl.name'; + } + + /** + * Compile the query for legacy versions of SQLite to determine the tables. + */ + public function compileLegacyTables(string $schema, bool $withSize = false): string + { + return $withSize + ? sprintf( + 'select m.tbl_name as name, %s as schema, sum(s.pgsize) as size from %s.sqlite_master as m ' + . 'join dbstat(%s) as s on s.name = m.name ' + . "where m.type in ('table', 'index') and m.tbl_name not like 'sqlite\\_%%' escape '\\' " + . 'group by m.tbl_name ' + . 'order by m.tbl_name', + $this->quoteString($schema), + $this->wrapValue($schema), + $this->quoteString($schema) + ) + : sprintf( + 'select name, %s as schema from %s.sqlite_master ' + . "where type = 'table' and name not like 'sqlite\\_%%' escape '\\' order by name", + $this->quoteString($schema), + $this->wrapValue($schema) + ); + } + + /** + * Compile the query to determine the views. + * + * @param null|string|string[] $schema + */ + public function compileViews(string|array|null $schema): string + { + return sprintf( + "select name, %s as schema, sql as definition from %s.sqlite_master where type = 'view' order by name", + $this->quoteString($schema), + $this->wrapValue($schema) + ); + } + + /** + * Compile the query to determine the columns. + */ + public function compileColumns(?string $schema, string $table): string + { + return sprintf( + 'select name, type, not "notnull" as "nullable", dflt_value as "default", pk as "primary", hidden as "extra" ' + . 'from pragma_table_xinfo(%s, %s) order by cid asc', + $this->quoteString($table), + $this->quoteString($schema ?? 'main') + ); + } + + /** + * Compile the query to determine the indexes. + */ + public function compileIndexes(?string $schema, string $table): string + { + return sprintf( + 'select \'primary\' as name, group_concat(col) as columns, 1 as "unique", 1 as "primary" ' + . 'from (select name as col from pragma_table_xinfo(%s, %s) where pk > 0 order by pk, cid) group by name ' + . 'union select name, group_concat(col) as columns, "unique", origin = \'pk\' as "primary" ' + . 'from (select il.*, ii.name as col from pragma_index_list(%s, %s) il, pragma_index_info(il.name, %s) ii order by il.seq, ii.seqno) ' + . 'group by name, "unique", "primary"', + $table = $this->quoteString($table), + $schema = $this->quoteString($schema ?? 'main'), + $table, + $schema, + $schema + ); + } + + /** + * Compile the query to determine the foreign keys. + */ + public function compileForeignKeys(?string $schema, string $table): string + { + return sprintf( + 'select group_concat("from") as columns, %s as foreign_schema, "table" as foreign_table, ' + . 'group_concat("to") as foreign_columns, on_update, on_delete ' + . 'from (select * from pragma_foreign_key_list(%s, %s) order by id desc, seq) ' + . 'group by id, "table", on_update, on_delete', + $schema = $this->quoteString($schema ?? 'main'), + $this->quoteString($table), + $schema + ); + } + + /** + * Compile a create table command. + */ + public function compileCreate(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + '%s table %s (%s%s%s)', + $blueprint->temporary ? 'create temporary' : 'create', + $this->wrapTable($blueprint), + implode(', ', $this->getColumns($blueprint)), + $this->addForeignKeys($this->getCommandsByName($blueprint, 'foreign')), + $this->addPrimaryKeys($this->getCommandByName($blueprint, 'primary')) + ); + } + + /** + * Get the foreign key syntax for a table creation statement. + * + * @param \Hypervel\Support\Fluent[] $foreignKeys + */ + protected function addForeignKeys(array $foreignKeys): string + { + return (new Collection($foreignKeys))->reduce(function ($sql, $foreign) { + // Once we have all the foreign key commands for the table creation statement + // we'll loop through each of them and add them to the create table SQL we + // are building, since SQLite needs foreign keys on the tables creation. + return $sql . $this->getForeignKey($foreign); + }, ''); + } + + /** + * Get the SQL for the foreign key. + */ + protected function getForeignKey(Fluent $foreign): string + { + // We need to columnize the columns that the foreign key is being defined for + // so that it is a properly formatted list. Once we have done this, we can + // return the foreign key SQL declaration to the calling method for use. + $sql = sprintf( + ', foreign key(%s) references %s(%s)', + $this->columnize($foreign->columns), + $this->wrapTable($foreign->on), + $this->columnize((array) $foreign->references) + ); + + if (! is_null($foreign->onDelete)) { + $sql .= " on delete {$foreign->onDelete}"; + } + + // If this foreign key specifies the action to be taken on update we will add + // that to the statement here. We'll append it to this SQL and then return + // this SQL so we can keep adding any other foreign constraints to this. + if (! is_null($foreign->onUpdate)) { + $sql .= " on update {$foreign->onUpdate}"; + } + + return $sql; + } + + /** + * Get the primary key syntax for a table creation statement. + */ + protected function addPrimaryKeys(?Fluent $primary): ?string + { + if (! is_null($primary)) { + return ", primary key ({$this->columnize($primary->columns)})"; + } + + return null; + } + + /** + * Compile alter table commands for adding columns. + */ + public function compileAdd(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'alter table %s add column %s', + $this->wrapTable($blueprint), + $this->getColumn($blueprint, $command->column) + ); + } + + /** + * Compile alter table command into a series of SQL statements. + * + * @return list + */ + public function compileAlter(Blueprint $blueprint, Fluent $command): array + { + $columnNames = []; + $autoIncrementColumn = null; + + $columns = (new Collection($blueprint->getState()->getColumns())) + ->map(function ($column) use ($blueprint, &$columnNames, &$autoIncrementColumn) { + $name = $this->wrap($column); + + $autoIncrementColumn = $column->autoIncrement ? $column->name : $autoIncrementColumn; + + if (is_null($column->virtualAs) && is_null($column->virtualAsJson) + && is_null($column->storedAs) && is_null($column->storedAsJson)) { + $columnNames[] = $name; + } + + return $this->addModifiers( + $this->wrap($column) . ' ' . ($column->full_type_definition ?? $this->getType($column)), + $blueprint, + $column + ); + })->all(); + + $indexes = (new Collection($blueprint->getState()->getIndexes())) + ->reject(fn ($index) => str_starts_with('sqlite_', $index->index)) + ->map(fn ($index) => $this->{'compile' . ucfirst($index->name)}($blueprint, $index)) + ->all(); + + [, $tableName] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + $tempTable = $this->wrapTable($blueprint, '__temp__' . $this->connection->getTablePrefix()); + $table = $this->wrapTable($blueprint); + $columnNames = implode(', ', $columnNames); + + $foreignKeyConstraintsEnabled = $this->connection->scalar($this->pragma('foreign_keys')); + + return array_filter(array_merge([ + $foreignKeyConstraintsEnabled ? $this->compileDisableForeignKeyConstraints() : null, + sprintf( + 'create table %s (%s%s%s)', + $tempTable, + implode(', ', $columns), + $this->addForeignKeys($blueprint->getState()->getForeignKeys()), + $autoIncrementColumn ? '' : $this->addPrimaryKeys($blueprint->getState()->getPrimaryKey()) + ), + sprintf('insert into %s (%s) select %s from %s', $tempTable, $columnNames, $columnNames, $table), + sprintf('drop table %s', $table), + sprintf('alter table %s rename to %s', $tempTable, $this->wrapTable($tableName)), + ], $indexes, [$foreignKeyConstraintsEnabled ? $this->compileEnableForeignKeyConstraints() : null])); + } + + #[Override] + public function compileChange(Blueprint $blueprint, Fluent $command): array|string + { + // Handled on table alteration... + return []; + } + + /** + * Compile a primary key command. + */ + public function compilePrimary(Blueprint $blueprint, Fluent $command): ?string + { + // Handled on table creation or alteration... + return null; + } + + /** + * Compile a unique key command. + */ + public function compileUnique(Blueprint $blueprint, Fluent $command): string + { + [$schema, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + + return sprintf( + 'create unique index %s%s on %s (%s)', + $schema ? $this->wrapValue($schema) . '.' : '', + $this->wrap($command->index), + $this->wrapTable($table), + $this->columnize($command->columns) + ); + } + + /** + * Compile a plain index key command. + */ + public function compileIndex(Blueprint $blueprint, Fluent $command): string + { + [$schema, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + + return sprintf( + 'create index %s%s on %s (%s)', + $schema ? $this->wrapValue($schema) . '.' : '', + $this->wrap($command->index), + $this->wrapTable($table), + $this->columnize($command->columns) + ); + } + + /** + * Compile a spatial index key command. + * + * @throws RuntimeException + */ + public function compileSpatialIndex(Blueprint $blueprint, Fluent $command): void + { + throw new RuntimeException('The database driver in use does not support spatial indexes.'); + } + + /** + * Compile a foreign key command. + */ + public function compileForeign(Blueprint $blueprint, Fluent $command): ?string + { + // Handled on table creation or alteration... + return null; + } + + /** + * Compile a drop table command. + */ + public function compileDrop(Blueprint $blueprint, Fluent $command): string + { + return 'drop table ' . $this->wrapTable($blueprint); + } + + /** + * Compile a drop table (if exists) command. + */ + public function compileDropIfExists(Blueprint $blueprint, Fluent $command): string + { + return 'drop table if exists ' . $this->wrapTable($blueprint); + } + + /** + * Compile the SQL needed to drop all tables. + */ + public function compileDropAllTables(?string $schema = null): string + { + return sprintf( + "delete from %s.sqlite_master where type in ('table', 'index', 'trigger')", + $this->wrapValue($schema ?? 'main') + ); + } + + /** + * Compile the SQL needed to drop all views. + */ + public function compileDropAllViews(?string $schema = null): string + { + return sprintf( + "delete from %s.sqlite_master where type in ('view')", + $this->wrapValue($schema ?? 'main') + ); + } + + /** + * Compile the SQL needed to rebuild the database. + */ + public function compileRebuild(?string $schema = null): string + { + return sprintf( + 'vacuum %s', + $this->wrapValue($schema ?? 'main') + ); + } + + /** + * Compile a drop column command. + * + * @return null|list + */ + public function compileDropColumn(Blueprint $blueprint, Fluent $command): ?array + { + if (version_compare($this->connection->getServerVersion(), '3.35', '<')) { + // Handled on table alteration... + + return null; + } + + $table = $this->wrapTable($blueprint); + + $columns = $this->prefixArray('drop column', $this->wrapArray($command->columns)); + + return (new Collection($columns))->map(fn ($column) => 'alter table ' . $table . ' ' . $column)->all(); + } + + /** + * Compile a drop primary key command. + */ + public function compileDropPrimary(Blueprint $blueprint, Fluent $command): ?string + { + // Handled on table alteration... + return null; + } + + /** + * Compile a drop unique key command. + */ + public function compileDropUnique(Blueprint $blueprint, Fluent $command): string + { + return $this->compileDropIndex($blueprint, $command); + } + + /** + * Compile a drop index command. + */ + public function compileDropIndex(Blueprint $blueprint, Fluent $command): string + { + [$schema] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + + return sprintf( + 'drop index %s%s', + $schema ? $this->wrapValue($schema) . '.' : '', + $this->wrap($command->index) + ); + } + + /** + * Compile a drop spatial index command. + * + * @throws RuntimeException + */ + public function compileDropSpatialIndex(Blueprint $blueprint, Fluent $command): void + { + throw new RuntimeException('The database driver in use does not support spatial indexes.'); + } + + /** + * Compile a drop foreign key command. + */ + public function compileDropForeign(Blueprint $blueprint, Fluent $command): ?array + { + if (empty($command->columns)) { + throw new RuntimeException('This database driver does not support dropping foreign keys by name.'); + } + + // Handled on table alteration... + return null; + } + + /** + * Compile a rename table command. + */ + public function compileRename(Blueprint $blueprint, Fluent $command): string + { + $from = $this->wrapTable($blueprint); + + return "alter table {$from} rename to " . $this->wrapTable($command->to); + } + + /** + * Compile a rename index command. + * + * @throws RuntimeException + */ + public function compileRenameIndex(Blueprint $blueprint, Fluent $command): array + { + $indexes = $this->connection->getSchemaBuilder()->getIndexes($blueprint->getTable()); + + $index = Arr::first($indexes, fn ($index) => $index['name'] === $command->from); + + if (! $index) { + throw new RuntimeException("Index [{$command->from}] does not exist."); + } + + if ($index['primary']) { + throw new RuntimeException('SQLite does not support altering primary keys.'); + } + + if ($index['unique']) { + return [ + $this->compileDropUnique($blueprint, new IndexDefinition(['index' => $index['name']])), + $this->compileUnique( + $blueprint, + new IndexDefinition(['index' => $command->to, 'columns' => $index['columns']]) + ), + ]; + } + + return [ + $this->compileDropIndex($blueprint, new IndexDefinition(['index' => $index['name']])), + $this->compileIndex( + $blueprint, + new IndexDefinition(['index' => $command->to, 'columns' => $index['columns']]) + ), + ]; + } + + /** + * Compile the command to enable foreign key constraints. + */ + #[Override] + public function compileEnableForeignKeyConstraints(): string + { + return $this->pragma('foreign_keys', 1); + } + + /** + * Compile the command to disable foreign key constraints. + */ + #[Override] + public function compileDisableForeignKeyConstraints(): string + { + return $this->pragma('foreign_keys', 0); + } + + /** + * Get the SQL to get or set a PRAGMA value. + */ + public function pragma(string $key, mixed $value = null): string + { + return sprintf( + 'pragma %s%s', + $key, + is_null($value) ? '' : ' = ' . $value + ); + } + + /** + * Create the column definition for a char type. + */ + protected function typeChar(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for a string type. + */ + protected function typeString(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for a tiny text type. + */ + protected function typeTinyText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a text type. + */ + protected function typeText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a medium text type. + */ + protected function typeMediumText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a long text type. + */ + protected function typeLongText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for an integer type. + */ + protected function typeInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a big integer type. + */ + protected function typeBigInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a medium integer type. + */ + protected function typeMediumInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a tiny integer type. + */ + protected function typeTinyInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a small integer type. + */ + protected function typeSmallInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a float type. + */ + protected function typeFloat(Fluent $column): string + { + return 'float'; + } + + /** + * Create the column definition for a double type. + */ + protected function typeDouble(Fluent $column): string + { + return 'double'; + } + + /** + * Create the column definition for a decimal type. + */ + protected function typeDecimal(Fluent $column): string + { + return 'numeric'; + } + + /** + * Create the column definition for a boolean type. + */ + protected function typeBoolean(Fluent $column): string + { + return 'tinyint(1)'; + } + + /** + * Create the column definition for an enumeration type. + */ + protected function typeEnum(Fluent $column): string + { + return sprintf( + 'varchar check ("%s" in (%s))', + $column->name, + $this->quoteString($column->allowed) + ); + } + + /** + * Create the column definition for a json type. + */ + protected function typeJson(Fluent $column): string + { + return $this->connection->getConfig('use_native_json') ? 'json' : 'text'; + } + + /** + * Create the column definition for a jsonb type. + */ + protected function typeJsonb(Fluent $column): string + { + return $this->connection->getConfig('use_native_jsonb') ? 'jsonb' : 'text'; + } + + /** + * Create the column definition for a date type. + */ + protected function typeDate(Fluent $column): string + { + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_DATE')); + } + + return 'date'; + } + + /** + * Create the column definition for a date-time type. + */ + protected function typeDateTime(Fluent $column): string + { + return $this->typeTimestamp($column); + } + + /** + * Create the column definition for a date-time (with time zone) type. + * + * Note: "SQLite does not have a storage class set aside for storing dates and/or times." + * + * @link https://www.sqlite.org/datatype3.html + */ + protected function typeDateTimeTz(Fluent $column): string + { + return $this->typeDateTime($column); + } + + /** + * Create the column definition for a time type. + */ + protected function typeTime(Fluent $column): string + { + return 'time'; + } + + /** + * Create the column definition for a time (with time zone) type. + */ + protected function typeTimeTz(Fluent $column): string + { + return $this->typeTime($column); + } + + /** + * Create the column definition for a timestamp type. + */ + protected function typeTimestamp(Fluent $column): string + { + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_TIMESTAMP')); + } + + return 'datetime'; + } + + /** + * Create the column definition for a timestamp (with time zone) type. + */ + protected function typeTimestampTz(Fluent $column): string + { + return $this->typeTimestamp($column); + } + + /** + * Create the column definition for a year type. + */ + protected function typeYear(Fluent $column): string + { + if ($column->useCurrent) { + $column->default(new Expression("(CAST(strftime('%Y', 'now') AS INTEGER))")); + } + + return $this->typeInteger($column); + } + + /** + * Create the column definition for a binary type. + */ + protected function typeBinary(Fluent $column): string + { + return 'blob'; + } + + /** + * Create the column definition for a uuid type. + */ + protected function typeUuid(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for an IP address type. + */ + protected function typeIpAddress(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for a MAC address type. + */ + protected function typeMacAddress(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for a spatial Geometry type. + */ + protected function typeGeometry(Fluent $column): string + { + return 'geometry'; + } + + /** + * Create the column definition for a spatial Geography type. + */ + protected function typeGeography(Fluent $column): string + { + return $this->typeGeometry($column); + } + + /** + * Create the column definition for a generated, computed column type. + * + * @throws RuntimeException + */ + protected function typeComputed(Fluent $column): void + { + throw new RuntimeException('This database driver requires a type, see the virtualAs / storedAs modifiers.'); + } + + /** + * Get the SQL for a generated virtual column modifier. + */ + protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($virtualAs = $column->virtualAsJson)) { + if ($this->isJsonSelector($virtualAs)) { + $virtualAs = $this->wrapJsonSelector($virtualAs); + } + + return " as ({$virtualAs})"; + } + + if (! is_null($virtualAs = $column->virtualAs)) { + return " as ({$this->getValue($virtualAs)})"; + } + + return null; + } + + /** + * Get the SQL for a generated stored column modifier. + */ + protected function modifyStoredAs(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($storedAs = $column->storedAsJson)) { + if ($this->isJsonSelector($storedAs)) { + $storedAs = $this->wrapJsonSelector($storedAs); + } + + return " as ({$storedAs}) stored"; + } + + if (! is_null($storedAs = $column->storedAs)) { + return " as ({$this->getValue($column->storedAs)}) stored"; + } + + return null; + } + + /** + * Get the SQL for a nullable column modifier. + */ + protected function modifyNullable(Blueprint $blueprint, Fluent $column): ?string + { + if (is_null($column->virtualAs) + && is_null($column->virtualAsJson) + && is_null($column->storedAs) + && is_null($column->storedAsJson)) { + return $column->nullable ? '' : ' not null'; + } + + if ($column->nullable === false) { + return ' not null'; + } + + return null; + } + + /** + * Get the SQL for a default column modifier. + */ + protected function modifyDefault(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->default) && is_null($column->virtualAs) && is_null($column->virtualAsJson) && is_null($column->storedAs)) { + return ' default ' . $this->getDefaultValue($column->default); + } + + return null; + } + + /** + * Get the SQL for an auto-increment column modifier. + */ + protected function modifyIncrement(Blueprint $blueprint, Fluent $column): ?string + { + if (in_array($column->type, $this->serials) && $column->autoIncrement) { + return ' primary key autoincrement'; + } + + return null; + } + + /** + * Get the SQL for a collation column modifier. + */ + protected function modifyCollate(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->collation)) { + return " collate '{$column->collation}'"; + } + + return null; + } + + /** + * Wrap the given JSON selector. + */ + protected function wrapJsonSelector(string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_extract(' . $field . $path . ')'; + } +} diff --git a/src/database/src/Schema/IndexDefinition.php b/src/database/src/Schema/IndexDefinition.php new file mode 100644 index 000000000..330f54b00 --- /dev/null +++ b/src/database/src/Schema/IndexDefinition.php @@ -0,0 +1,20 @@ +connectionString() . ' --database="${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"'; + + $process = $this->makeProcess($command)->setTimeout(null); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the base dump command arguments for MariaDB as a string. + */ + #[Override] + protected function baseDumpCommand(): string + { + $command = 'mariadb-dump ' . $this->connectionString() . ' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc'; + + return $command . ' "${:LARAVEL_LOAD_DATABASE}"'; + } +} diff --git a/src/database/src/Schema/MySqlBuilder.php b/src/database/src/Schema/MySqlBuilder.php new file mode 100755 index 000000000..946df8325 --- /dev/null +++ b/src/database/src/Schema/MySqlBuilder.php @@ -0,0 +1,62 @@ +getTableListing($this->getCurrentSchemaListing()); + + if (empty($tables)) { + return; + } + + $this->disableForeignKeyConstraints(); + + try { + $this->connection->statement( + $this->grammar->compileDropAllTables($tables) + ); + } finally { + $this->enableForeignKeyConstraints(); + } + } + + /** + * Drop all views from the database. + */ + #[Override] + public function dropAllViews(): void + { + $views = array_column($this->getViews($this->getCurrentSchemaListing()), 'schema_qualified_name'); + + if (empty($views)) { + return; + } + + $this->connection->statement( + $this->grammar->compileDropAllViews($views) + ); + } + + /** + * Get the names of current schemas for the connection. + */ + #[Override] + public function getCurrentSchemaListing(): array + { + return [$this->connection->getDatabaseName()]; + } +} diff --git a/src/database/src/Schema/MySqlSchemaState.php b/src/database/src/Schema/MySqlSchemaState.php new file mode 100644 index 000000000..50e4f0ba2 --- /dev/null +++ b/src/database/src/Schema/MySqlSchemaState.php @@ -0,0 +1,163 @@ +executeDumpProcess($this->makeProcess( + $this->baseDumpCommand() . ' --routines --result-file="${:LARAVEL_LOAD_PATH}" --no-data' + ), $this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + + $this->removeAutoIncrementingState($path); + + if ($this->hasMigrationTable()) { + $this->appendMigrationData($path); + } + } + + /** + * Remove the auto-incrementing state from the given schema dump. + */ + protected function removeAutoIncrementingState(string $path): void + { + $this->files->put($path, preg_replace( + '/\s+AUTO_INCREMENT=[0-9]+/iu', + '', + $this->files->get($path) + )); + } + + /** + * Append the migration data to the schema dump. + */ + protected function appendMigrationData(string $path): void + { + $process = $this->executeDumpProcess($this->makeProcess( + $this->baseDumpCommand() . ' ' . $this->getMigrationTable() . ' --no-create-info --skip-extended-insert --skip-routines --compact --complete-insert' + ), null, array_merge($this->baseVariables($this->connection->getConfig()), [ + ])); + + $this->files->append($path, $process->getOutput()); + } + + /** + * Load the given schema file into the database. + */ + #[Override] + public function load(string $path): void + { + $command = 'mysql ' . $this->connectionString() . ' --database="${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"'; + + $process = $this->makeProcess($command)->setTimeout(null); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the base dump command arguments for MySQL as a string. + */ + protected function baseDumpCommand(): string + { + $command = 'mysqldump ' . $this->connectionString() . ' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc --column-statistics=0'; + + if (! $this->connection->isMaria()) { + $command .= ' --set-gtid-purged=OFF'; + } + + return $command . ' "${:LARAVEL_LOAD_DATABASE}"'; + } + + /** + * Generate a basic connection string (--socket, --host, --port, --user, --password) for the database. + */ + protected function connectionString(): string + { + $value = ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}"'; + + $config = $this->connection->getConfig(); + + $value .= $config['unix_socket'] ?? false + ? ' --socket="${:LARAVEL_LOAD_SOCKET}"' + : ' --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}"'; + + /* @phpstan-ignore class.notFound */ + if (isset($config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA])) { + $value .= ' --ssl-ca="${:LARAVEL_LOAD_SSL_CA}"'; + } + + // if (isset($config['options'][\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT]) && + // $config['options'][\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] === false) { + // $value .= ' --ssl=off'; + // } + + return $value; + } + + /** + * Get the base variables for a dump / load command. + */ + #[Override] + protected function baseVariables(array $config): array + { + $config['host'] ??= ''; + + return [ + 'LARAVEL_LOAD_SOCKET' => $config['unix_socket'] ?? '', + 'LARAVEL_LOAD_HOST' => is_array($config['host']) ? $config['host'][0] : $config['host'], + 'LARAVEL_LOAD_PORT' => $config['port'] ?? '', + 'LARAVEL_LOAD_USER' => $config['username'], + 'LARAVEL_LOAD_PASSWORD' => $config['password'] ?? '', + 'LARAVEL_LOAD_DATABASE' => $config['database'], + 'LARAVEL_LOAD_SSL_CA' => $config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA] ?? '', // @phpstan-ignore class.notFound + ]; + } + + /** + * Execute the given dump process. + */ + protected function executeDumpProcess(Process $process, ?callable $output, array $variables, int $depth = 0): Process + { + if ($depth > 30) { + throw new Exception('Dump execution exceeded maximum depth of 30.'); + } + + try { + $process->setTimeout(null)->mustRun($output, $variables); + } catch (Exception $e) { + if (Str::contains($e->getMessage(), ['column-statistics', 'column_statistics'])) { + return $this->executeDumpProcess(Process::fromShellCommandLine( + str_replace(' --column-statistics=0', '', $process->getCommandLine()) + ), $output, $variables, $depth + 1); + } + + if (str_contains($e->getMessage(), 'set-gtid-purged')) { + return $this->executeDumpProcess(Process::fromShellCommandLine( + str_replace(' --set-gtid-purged=OFF', '', $process->getCommandLine()) + ), $output, $variables, $depth + 1); + } + + throw $e; + } + + return $process; + } +} diff --git a/src/database/src/Schema/PostgresBuilder.php b/src/database/src/Schema/PostgresBuilder.php new file mode 100755 index 000000000..4653e45d9 --- /dev/null +++ b/src/database/src/Schema/PostgresBuilder.php @@ -0,0 +1,102 @@ +connection->getConfig('dont_drop') ?? ['spatial_ref_sys']; + + foreach ($this->getTables($this->getCurrentSchemaListing()) as $table) { + if (empty(array_intersect([$table['name'], $table['schema_qualified_name']], $excludedTables))) { + $tables[] = $table['schema_qualified_name']; + } + } + + if (empty($tables)) { + return; + } + + $this->connection->statement( + $this->grammar->compileDropAllTables($tables) + ); + } + + /** + * Drop all views from the database. + */ + #[Override] + public function dropAllViews(): void + { + $views = array_column($this->getViews($this->getCurrentSchemaListing()), 'schema_qualified_name'); + + if (empty($views)) { + return; + } + + $this->connection->statement( + $this->grammar->compileDropAllViews($views) + ); + } + + /** + * Drop all types from the database. + */ + #[Override] + public function dropAllTypes(): void + { + $types = []; + $domains = []; + + foreach ($this->getTypes($this->getCurrentSchemaListing()) as $type) { + if (! $type['implicit']) { + if ($type['type'] === 'domain') { + $domains[] = $type['schema_qualified_name']; + } else { + $types[] = $type['schema_qualified_name']; + } + } + } + + if (! empty($types)) { + $this->connection->statement($this->grammar->compileDropAllTypes($types)); + } + + if (! empty($domains)) { + $this->connection->statement($this->grammar->compileDropAllDomains($domains)); + } + } + + /** + * Get the current schemas for the connection. + */ + #[Override] + public function getCurrentSchemaListing(): array + { + return array_map( + fn ($schema) => $schema === '$user' ? $this->connection->getConfig('username') : $schema, + $this->parseSearchPath( + $this->connection->getConfig('search_path') + ?: $this->connection->getConfig('schema') + ?: 'public' + ) + ); + } +} diff --git a/src/database/src/Schema/PostgresSchemaState.php b/src/database/src/Schema/PostgresSchemaState.php new file mode 100644 index 000000000..0b4649343 --- /dev/null +++ b/src/database/src/Schema/PostgresSchemaState.php @@ -0,0 +1,88 @@ +baseDumpCommand() . ' --schema-only > ' . $path, + ]); + + if ($this->hasMigrationTable()) { + $commands->push($this->baseDumpCommand() . ' -t ' . $this->getMigrationTable() . ' --data-only >> ' . $path); + } + + $commands->map(function ($command, $path) { + $this->makeProcess($command)->mustRun($this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + }); + } + + /** + * Load the given schema file into the database. + */ + #[Override] + public function load(string $path): void + { + $command = 'pg_restore --no-owner --no-acl --clean --if-exists --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}" "${:LARAVEL_LOAD_PATH}"'; + + if (str_ends_with($path, '.sql')) { + $command = 'psql --file="${:LARAVEL_LOAD_PATH}" --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; + } + + $process = $this->makeProcess($command); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the name of the application's migration table. + */ + #[Override] + protected function getMigrationTable(): string + { + [$schema, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($this->migrationTable, withDefaultSchema: true); + + return $schema . '.' . $this->connection->getTablePrefix() . $table; + } + + /** + * Get the base dump command arguments for PostgreSQL as a string. + */ + protected function baseDumpCommand(): string + { + return 'pg_dump --no-owner --no-acl --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; + } + + /** + * Get the base variables for a dump / load command. + */ + #[Override] + protected function baseVariables(array $config): array + { + $config['host'] ??= ''; + + return [ + 'LARAVEL_LOAD_HOST' => is_array($config['host']) ? $config['host'][0] : $config['host'], + 'LARAVEL_LOAD_PORT' => $config['port'] ?? '', + 'LARAVEL_LOAD_USER' => $config['username'], + 'PGPASSWORD' => $config['password'], + 'LARAVEL_LOAD_DATABASE' => $config['database'], + ]; + } +} diff --git a/src/database/src/Schema/SQLiteBuilder.php b/src/database/src/Schema/SQLiteBuilder.php new file mode 100644 index 000000000..fec7f1684 --- /dev/null +++ b/src/database/src/Schema/SQLiteBuilder.php @@ -0,0 +1,165 @@ +connection->scalar($this->grammar->compileDbstatExists()); + } catch (QueryException) { + $withSize = false; + } + + if (version_compare($this->connection->getServerVersion(), '3.37.0', '<')) { + $schema ??= array_column($this->getSchemas(), 'name'); + + $tables = []; + + foreach (Arr::wrap($schema) as $name) { + $tables = array_merge($tables, $this->connection->selectFromWriteConnection( + $this->grammar->compileLegacyTables($name, $withSize) + )); + } + + return $this->connection->getPostProcessor()->processTables($tables); + } + + return $this->connection->getPostProcessor()->processTables( + $this->connection->selectFromWriteConnection( + $this->grammar->compileTables($schema, $withSize) + ) + ); + } + + #[Override] + public function getViews(array|string|null $schema = null): array + { + $schema ??= array_column($this->getSchemas(), 'name'); + + $views = []; + + foreach (Arr::wrap($schema) as $name) { + $views = array_merge($views, $this->connection->selectFromWriteConnection( + $this->grammar->compileViews($name) + )); + } + + return $this->connection->getPostProcessor()->processViews($views); + } + + #[Override] + public function getColumns(string $table): array + { + [$schema, $table] = $this->parseSchemaAndTable($table); + + $table = $this->connection->getTablePrefix() . $table; + + return $this->connection->getPostProcessor()->processColumns( + $this->connection->selectFromWriteConnection($this->grammar->compileColumns($schema, $table)), + $this->connection->scalar($this->grammar->compileSqlCreateStatement($schema, $table)) + ); + } + + /** + * Drop all tables from the database. + */ + #[Override] + public function dropAllTables(): void + { + foreach ($this->getCurrentSchemaListing() as $schema) { + $database = $schema === 'main' + ? $this->connection->getDatabaseName() + : (array_column($this->getSchemas(), 'path', 'name')[$schema] ?: ':memory:'); + + if ($database !== ':memory:' + && ! str_contains($database, '?mode=memory') + && ! str_contains($database, '&mode=memory') + ) { + $this->refreshDatabaseFile($database); + } else { + $this->pragma('writable_schema', 1); + + $this->connection->statement($this->grammar->compileDropAllTables($schema)); + + $this->pragma('writable_schema', 0); + + $this->connection->statement($this->grammar->compileRebuild($schema)); + } + } + } + + /** + * Drop all views from the database. + */ + #[Override] + public function dropAllViews(): void + { + foreach ($this->getCurrentSchemaListing() as $schema) { + $this->pragma('writable_schema', 1); + + $this->connection->statement($this->grammar->compileDropAllViews($schema)); + + $this->pragma('writable_schema', 0); + + $this->connection->statement($this->grammar->compileRebuild($schema)); + } + } + + /** + * Get the value for the given pragma name or set the given value. + */ + public function pragma(string $key, mixed $value = null): mixed + { + return is_null($value) + ? $this->connection->scalar($this->grammar->pragma($key)) + : $this->connection->statement($this->grammar->pragma($key, $value)); + } + + /** + * Empty the database file. + */ + public function refreshDatabaseFile(?string $path = null): void + { + file_put_contents($path ?? $this->connection->getDatabaseName(), ''); + } + + /** + * Get the names of current schemas for the connection. + */ + #[Override] + public function getCurrentSchemaListing(): array + { + return ['main']; + } +} diff --git a/src/database/src/Schema/SchemaProxy.php b/src/database/src/Schema/SchemaProxy.php new file mode 100644 index 000000000..ee7cf70e9 --- /dev/null +++ b/src/database/src/Schema/SchemaProxy.php @@ -0,0 +1,33 @@ +connection() + ->{$name}(...$arguments); + } + + /** + * Get schema builder with specific connection. + * + * Routes through DatabaseManager to respect usingConnection() overrides. + */ + public function connection(?string $name = null): Builder + { + return ApplicationContext::getContainer() + ->get(DatabaseManager::class) + ->connection($name) + ->getSchemaBuilder(); + } +} diff --git a/src/database/src/Schema/SchemaState.php b/src/database/src/Schema/SchemaState.php new file mode 100644 index 000000000..6d87b31ae --- /dev/null +++ b/src/database/src/Schema/SchemaState.php @@ -0,0 +1,117 @@ +connection = $connection; + + $this->files = $files ?: new Filesystem(); + + $this->processFactory = $processFactory ?: function (...$arguments) { + return Process::fromShellCommandline(...$arguments)->setTimeout(null); + }; + + $this->handleOutputUsing(function () { + }); + } + + /** + * Dump the database's schema into a file. + */ + abstract public function dump(Connection $connection, string $path): void; + + /** + * Load the given schema file into the database. + */ + abstract public function load(string $path): void; + + /** + * Get the base variables for a dump / load command. + * + * @param array $config + * @return array + */ + abstract protected function baseVariables(array $config): array; + + /** + * Create a new process instance. + */ + public function makeProcess(mixed ...$arguments): Process + { + return call_user_func($this->processFactory, ...$arguments); + } + + /** + * Determine if the current connection has a migration table. + */ + public function hasMigrationTable(): bool + { + return $this->connection->getSchemaBuilder()->hasTable($this->migrationTable); + } + + /** + * Get the name of the application's migration table. + */ + protected function getMigrationTable(): string + { + return $this->connection->getTablePrefix() . $this->migrationTable; + } + + /** + * Specify the name of the application's migration table. + */ + public function withMigrationTable(string $table): static + { + $this->migrationTable = $table; + + return $this; + } + + /** + * Specify the callback that should be used to handle process output. + */ + public function handleOutputUsing(callable $output): static + { + $this->output = $output; + + return $this; + } +} diff --git a/src/database/src/Schema/SqliteSchemaState.php b/src/database/src/Schema/SqliteSchemaState.php new file mode 100644 index 000000000..c9a4c40d1 --- /dev/null +++ b/src/database/src/Schema/SqliteSchemaState.php @@ -0,0 +1,92 @@ +makeProcess($this->baseCommand() . ' ".schema --indent"') + ->setTimeout(null) + ->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + ])); + + $migrations = preg_replace('/CREATE TABLE sqlite_.+?\);[\r\n]+/is', '', $process->getOutput()); + + $this->files->put($path, $migrations . PHP_EOL); + + if ($this->hasMigrationTable()) { + $this->appendMigrationData($path); + } + } + + /** + * Append the migration data to the schema dump. + */ + protected function appendMigrationData(string $path): void + { + $process = $this->makeProcess( + $this->baseCommand() . ' ".dump \'' . $this->getMigrationTable() . '\'"' + )->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + ])); + + $migrations = (new Collection(preg_split("/\r\n|\n|\r/", $process->getOutput()))) + ->filter(fn ($line) => preg_match('/^\s*(--|INSERT\s)/iu', $line) === 1 && strlen($line) > 0) + ->all(); + + $this->files->append($path, implode(PHP_EOL, $migrations) . PHP_EOL); + } + + /** + * Load the given schema file into the database. + */ + #[Override] + public function load(string $path): void + { + $database = $this->connection->getDatabaseName(); + + if ($database === ':memory:' + || str_contains($database, '?mode=memory') + || str_contains($database, '&mode=memory') + ) { + $this->connection->getPdo()->exec($this->files->get($path)); + + return; + } + + $process = $this->makeProcess($this->baseCommand() . ' < "${:LARAVEL_LOAD_PATH}"'); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the base sqlite command arguments as a string. + */ + protected function baseCommand(): string + { + return 'sqlite3 "${:LARAVEL_LOAD_DATABASE}"'; + } + + /** + * Get the base variables for a dump / load command. + */ + #[Override] + protected function baseVariables(array $config): array + { + return [ + 'LARAVEL_LOAD_DATABASE' => $config['database'], + ]; + } +} diff --git a/src/core/src/Database/Seeder.php b/src/database/src/Seeder.php old mode 100644 new mode 100755 similarity index 68% rename from src/core/src/Database/Seeder.php rename to src/database/src/Seeder.php index 194e7cac8..a8d604480 --- a/src/core/src/Database/Seeder.php +++ b/src/database/src/Seeder.php @@ -6,18 +6,17 @@ use FriendsOfHyperf\PrettyConsole\View\Components\TwoColumnDetail; use Hypervel\Console\Command; -use Hypervel\Container\Contracts\Container as ContainerContract; +use Hypervel\Contracts\Container\Container; +use Hypervel\Database\Console\Seeds\WithoutModelEvents; use Hypervel\Support\Arr; use InvalidArgumentException; -use function Hyperf\Support\with; - abstract class Seeder { /** * The container instance. */ - protected ContainerContract $container; + protected Container $container; /** * The console command instance. @@ -26,11 +25,15 @@ abstract class Seeder /** * Seeders that have been called at least one time. + * + * @var array */ protected static array $called = []; /** * Run the given seeder class. + * + * @param array|class-string $class */ public function call(array|string $class, bool $silent = false, array $parameters = []): static { @@ -42,10 +45,8 @@ public function call(array|string $class, bool $silent = false, array $parameter $name = get_class($seeder); if ($silent === false && isset($this->command)) { - with(new TwoColumnDetail($this->command->getOutput()))->render( - $name, - 'RUNNING' - ); + (new TwoColumnDetail($this->command->getOutput())) + ->render($name, 'RUNNING'); } $startTime = microtime(true); @@ -55,10 +56,8 @@ public function call(array|string $class, bool $silent = false, array $parameter if ($silent === false && isset($this->command)) { $runTime = number_format((microtime(true) - $startTime) * 1000); - with(new TwoColumnDetail($this->command->getOutput()))->render( - $name, - "{$runTime} ms DONE" - ); + (new TwoColumnDetail($this->command->getOutput())) + ->render($name, "{$runTime} ms DONE"); $this->command->getOutput()->writeln(''); } @@ -71,24 +70,30 @@ public function call(array|string $class, bool $silent = false, array $parameter /** * Run the given seeder class. + * + * @param array|class-string $class */ - public function callWith(array|string $class, array $parameters = []): void + public function callWith(array|string $class, array $parameters = []): static { - $this->call($class, false, $parameters); + return $this->call($class, false, $parameters); } /** * Silently run the given seeder class. + * + * @param array|class-string $class */ - public function callSilent(array|string $class, array $parameters = []): void + public function callSilent(array|string $class, array $parameters = []): static { - $this->call($class, true, $parameters); + return $this->call($class, true, $parameters); } /** * Run the given seeder class once. + * + * @param array|class-string $class */ - public function callOnce(array|string $class, bool $silent = false, array $parameters = []): void + public function callOnce(array|string $class, bool $silent = false, array $parameters = []): static { $classes = Arr::wrap($class); @@ -99,6 +104,8 @@ public function callOnce(array|string $class, bool $silent = false, array $param $this->call($class, $silent, $parameters); } + + return $this; } /** @@ -107,7 +114,7 @@ public function callOnce(array|string $class, bool $silent = false, array $param protected function resolve(string $class): Seeder { if (isset($this->container)) { - $instance = $this->container->get($class); + $instance = $this->container->make($class); $instance->setContainer($this->container); } else { @@ -124,7 +131,7 @@ protected function resolve(string $class): Seeder /** * Set the IoC container instance. */ - public function setContainer(ContainerContract $container): static + public function setContainer(Container $container): static { $this->container = $container; @@ -154,7 +161,14 @@ public function __invoke(array $parameters = []): mixed $callback = fn () => isset($this->container) ? $this->container->call([$this, 'run'], $parameters) - : $this->run(...$parameters); // @phpstan-ignore-line + : $this->run(...$parameters); + + $uses = array_flip(class_uses_recursive(static::class)); + + if (isset($uses[WithoutModelEvents::class])) { + // @phpstan-ignore method.notFound (method provided by WithoutModelEvents trait when used) + $callback = $this->withoutModelEvents($callback); + } return $callback(); } diff --git a/src/database/src/SimpleConnectionResolver.php b/src/database/src/SimpleConnectionResolver.php new file mode 100644 index 000000000..2b1cc1152 --- /dev/null +++ b/src/database/src/SimpleConnectionResolver.php @@ -0,0 +1,63 @@ +manager->resolveConnectionDirectly( + enum_value($name) ?? $this->getDefaultConnection() + ); + } + + /** + * Get the default connection name. + */ + public function getDefaultConnection(): string + { + return $this->default; + } + + /** + * Set the default connection name. + */ + public function setDefaultConnection(string $name): void + { + $this->default = $name; + } +} diff --git a/src/database/src/UniqueConstraintViolationException.php b/src/database/src/UniqueConstraintViolationException.php new file mode 100644 index 000000000..181721efd --- /dev/null +++ b/src/database/src/UniqueConstraintViolationException.php @@ -0,0 +1,9 @@ +shouldDeferEvent($event)) { Context::override('__event.deferred_events', function (?array $events) use ($event, $payload, $halt) { @@ -148,7 +148,7 @@ public function listen( /** * Fire an event until the first non-null response is returned. */ - public function until(object|string $event, mixed $payload = []): object|string + public function until(object|string $event, mixed $payload = []): mixed { return $this->dispatch($event, $payload, true); } @@ -156,7 +156,7 @@ public function until(object|string $event, mixed $payload = []): object|string /** * Broadcast an event and call the listeners. */ - protected function invokeListeners(object|string $event, mixed $payload, bool $halt = false): object|string + protected function invokeListeners(object|string $event, mixed $payload, bool $halt = false): mixed { if ($this->shouldBroadcast($event)) { $this->broadcastEvent($event); @@ -167,7 +167,13 @@ protected function invokeListeners(object|string $event, mixed $payload, bool $h $this->dump($listener, $event); - if ($halt || $response === false || ($event instanceof StoppableEventInterface && $event->isPropagationStopped())) { + // If halting and listener returned a non-null response, return it immediately + if ($halt && ! is_null($response)) { + return $response; + } + + // If listener returns false, stop propagation + if ($response === false || ($event instanceof StoppableEventInterface && $event->isPropagationStopped())) { break; } } @@ -378,8 +384,12 @@ public function setQueueResolver(callable $resolver): static /** * Get the database transaction manager implementation from the resolver. */ - protected function resolveTransactionManager(): ?TransactionManager + protected function resolveTransactionManager(): ?DatabaseTransactionsManager { + if ($this->transactionManagerResolver === null) { + return null; + } + return call_user_func($this->transactionManagerResolver); } diff --git a/src/event/src/EventDispatcherFactory.php b/src/event/src/EventDispatcherFactory.php index 066addb46..1a2a7015f 100644 --- a/src/event/src/EventDispatcherFactory.php +++ b/src/event/src/EventDispatcherFactory.php @@ -5,7 +5,8 @@ namespace Hypervel\Event; use Hyperf\Contract\StdoutLoggerInterface; -use Hypervel\Queue\Contracts\Factory as QueueFactoryContract; +use Hypervel\Contracts\Queue\Factory as QueueFactoryContract; +use Hypervel\Database\DatabaseTransactionsManager; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\ListenerProviderInterface; @@ -19,6 +20,12 @@ public function __invoke(ContainerInterface $container) $dispatcher->setQueueResolver(fn () => $container->get(QueueFactoryContract::class)); + $dispatcher->setTransactionManagerResolver( + fn () => $container->has(DatabaseTransactionsManager::class) + ? $container->get(DatabaseTransactionsManager::class) + : null + ); + return $dispatcher; } } diff --git a/src/event/src/ListenerProvider.php b/src/event/src/ListenerProvider.php index 7379e30c2..9d1d4afdf 100644 --- a/src/event/src/ListenerProvider.php +++ b/src/event/src/ListenerProvider.php @@ -4,12 +4,10 @@ namespace Hypervel\Event; -use Hyperf\Collection\Collection; use Hyperf\Stdlib\SplPriorityQueue; -use Hyperf\Stringable\Str; use Hypervel\Event\Contracts\ListenerProvider as ListenerProviderContract; - -use function Hyperf\Collection\collect; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; class ListenerProvider implements ListenerProviderContract { diff --git a/src/event/src/NullDispatcher.php b/src/event/src/NullDispatcher.php new file mode 100644 index 000000000..68c71977d --- /dev/null +++ b/src/event/src/NullDispatcher.php @@ -0,0 +1,128 @@ +dispatcher->listen($events, $listener, $priority); + } + + /** + * Determine if a given event has listeners. + */ + public function hasListeners(string $eventName): bool + { + return $this->dispatcher->hasListeners($eventName); + } + + /** + * Determine if the given event has any wildcard listeners. + */ + public function hasWildcardListeners(string $eventName): bool + { + return $this->dispatcher->hasWildcardListeners($eventName); + } + + /** + * Register an event subscriber with the dispatcher. + */ + public function subscribe(object|string $subscriber): void + { + $this->dispatcher->subscribe($subscriber); + } + + /** + * Flush a set of pushed events. + */ + public function flush(string $event): void + { + $this->dispatcher->flush($event); + } + + /** + * Remove a set of listeners from the dispatcher. + */ + public function forget(string $event): void + { + $this->dispatcher->forget($event); + } + + /** + * Forget all of the queued listeners. + */ + public function forgetPushed(): void + { + $this->dispatcher->forgetPushed(); + } + + /** + * Get all of the listeners for a given event name. + */ + public function getListeners(object|string $eventName): iterable + { + return $this->dispatcher->getListeners($eventName); + } + + /** + * Gets the raw, unprepared listeners. + */ + public function getRawListeners(): array + { + return $this->dispatcher->getRawListeners(); + } + + /** + * Dynamically pass method calls to the underlying dispatcher. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->forwardCallTo($this->dispatcher, $method, $parameters); + } +} diff --git a/src/filesystem/composer.json b/src/filesystem/composer.json index de85106be..4bc54b573 100644 --- a/src/filesystem/composer.json +++ b/src/filesystem/composer.json @@ -30,8 +30,8 @@ }, "require": { "php": "^8.2", - "hyperf/collection": "~3.1.0", - "hyperf/macroable": "~3.1.0", + "hypervel/collections": "~0.3.0", + "hypervel/macroable": "~0.3.0", "hyperf/support": "~3.1.0", "hyperf/conditionable": "~3.1.0", "hypervel/object-pool": "^0.3" diff --git a/src/filesystem/src/CloudStorageFactory.php b/src/filesystem/src/CloudStorageFactory.php index 4356ea832..3c51360a8 100644 --- a/src/filesystem/src/CloudStorageFactory.php +++ b/src/filesystem/src/CloudStorageFactory.php @@ -4,8 +4,8 @@ namespace Hypervel\Filesystem; -use Hypervel\Filesystem\Contracts\Cloud as CloudContract; -use Hypervel\Filesystem\Contracts\Factory as FactoryContract; +use Hypervel\Contracts\Filesystem\Cloud as CloudContract; +use Hypervel\Contracts\Filesystem\Factory as FactoryContract; use Psr\Container\ContainerInterface; class CloudStorageFactory diff --git a/src/filesystem/src/ConfigProvider.php b/src/filesystem/src/ConfigProvider.php index 0dded6259..a101a1860 100644 --- a/src/filesystem/src/ConfigProvider.php +++ b/src/filesystem/src/ConfigProvider.php @@ -4,9 +4,9 @@ namespace Hypervel\Filesystem; -use Hypervel\Filesystem\Contracts\Cloud as CloudContract; -use Hypervel\Filesystem\Contracts\Factory as FactoryContract; -use Hypervel\Filesystem\Contracts\Filesystem as FilesystemContract; +use Hypervel\Contracts\Filesystem\Cloud as CloudContract; +use Hypervel\Contracts\Filesystem\Factory as FactoryContract; +use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract; class ConfigProvider { diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php index f078128ee..dd4354a96 100644 --- a/src/filesystem/src/Filesystem.php +++ b/src/filesystem/src/Filesystem.php @@ -8,6 +8,14 @@ class Filesystem extends HyperfFilesystem { + /** + * Determine if a file or directory is missing. + */ + public function missing(string $path): bool + { + return ! $this->exists($path); + } + /** * Ensure a directory exists. */ diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index 51ca8d38d..79a0e277a 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -7,18 +7,18 @@ use BadMethodCallException; use Closure; use DateTimeInterface; -use Hyperf\Collection\Arr; use Hyperf\Conditionable\Conditionable; use Hyperf\Context\ApplicationContext; use Hyperf\HttpMessage\Upload\UploadedFile; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Str; -use Hypervel\Filesystem\Contracts\Cloud as CloudFilesystemContract; -use Hypervel\Filesystem\Contracts\Filesystem as FilesystemContract; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Filesystem\Cloud as CloudFilesystemContract; +use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Hypervel\Http\HeaderUtils; use Hypervel\Http\StreamOutput; +use Hypervel\Support\Arr; +use Hypervel\Support\Str; +use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; use League\Flysystem\FilesystemAdapter as FlysystemAdapter; use League\Flysystem\FilesystemOperator; diff --git a/src/filesystem/src/FilesystemFactory.php b/src/filesystem/src/FilesystemFactory.php index 343983345..bd29775c5 100644 --- a/src/filesystem/src/FilesystemFactory.php +++ b/src/filesystem/src/FilesystemFactory.php @@ -4,8 +4,8 @@ namespace Hypervel\Filesystem; -use Hypervel\Filesystem\Contracts\Factory as FactoryContract; -use Hypervel\Filesystem\Contracts\Filesystem as FilesystemContract; +use Hypervel\Contracts\Filesystem\Factory as FactoryContract; +use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract; use Psr\Container\ContainerInterface; class FilesystemFactory diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 9109b214e..3f468dae2 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -7,13 +7,13 @@ use Aws\S3\S3Client; use Closure; use Google\Cloud\Storage\StorageClient as GcsClient; -use Hyperf\Collection\Arr; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; -use Hypervel\Filesystem\Contracts\Cloud; -use Hypervel\Filesystem\Contracts\Factory as FactoryContract; -use Hypervel\Filesystem\Contracts\Filesystem; +use Hypervel\Contracts\Filesystem\Cloud; +use Hypervel\Contracts\Filesystem\Factory as FactoryContract; +use Hypervel\Contracts\Filesystem\Filesystem; use Hypervel\ObjectPool\Traits\HasPoolProxy; +use Hypervel\Support\Arr; +use Hypervel\Support\Str; use InvalidArgumentException; use League\Flysystem\AwsS3V3\AwsS3V3Adapter as S3Adapter; use League\Flysystem\AwsS3V3\PortableVisibilityConverter as AwsS3PortableVisibilityConverter; diff --git a/src/filesystem/src/FilesystemPoolProxy.php b/src/filesystem/src/FilesystemPoolProxy.php index d2c3bed52..bc373c7ad 100644 --- a/src/filesystem/src/FilesystemPoolProxy.php +++ b/src/filesystem/src/FilesystemPoolProxy.php @@ -5,7 +5,7 @@ namespace Hypervel\Filesystem; use Hyperf\HttpMessage\Upload\UploadedFile; -use Hypervel\Filesystem\Contracts\Cloud; +use Hypervel\Contracts\Filesystem\Cloud; use Hypervel\ObjectPool\PoolProxy; use Psr\Http\Message\StreamInterface; use RuntimeException; diff --git a/src/filesystem/src/GoogleCloudStorageAdapter.php b/src/filesystem/src/GoogleCloudStorageAdapter.php index 713f615d0..31027bfd6 100644 --- a/src/filesystem/src/GoogleCloudStorageAdapter.php +++ b/src/filesystem/src/GoogleCloudStorageAdapter.php @@ -7,7 +7,7 @@ use DateTimeInterface; use Google\Cloud\Storage\Bucket; use Google\Cloud\Storage\StorageClient; -use Hyperf\Collection\Arr; +use Hypervel\Support\Arr; use League\Flysystem\FilesystemOperator; use League\Flysystem\GoogleCloudStorage\GoogleCloudStorageAdapter as FlysystemGoogleCloudAdapter; use League\Flysystem\UnableToReadFile; diff --git a/src/filesystem/src/LockableFile.php b/src/filesystem/src/LockableFile.php index d05c3854c..a5b1660a0 100644 --- a/src/filesystem/src/LockableFile.php +++ b/src/filesystem/src/LockableFile.php @@ -8,7 +8,7 @@ use Exception; use Hyperf\Coroutine\Coroutine; use Hyperf\Coroutine\Locker; -use Hypervel\Filesystem\Exceptions\LockTimeoutException; +use Hypervel\Contracts\Filesystem\LockTimeoutException; class LockableFile { diff --git a/src/foundation/composer.json b/src/foundation/composer.json index 5e4c23490..429cd2fe8 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -23,6 +23,7 @@ "php": "^8.2", "nesbot/carbon": "^2.72.6", "hypervel/core": "^0.3", + "hypervel/database": "^0.3", "hypervel/filesystem": "^0.3", "hypervel/support": "^0.3", "hypervel/http": "^0.3", @@ -31,16 +32,13 @@ "hyperf/di": "~3.1.0", "hyperf/support": "~3.1.0", "hyperf/command": "~3.1.0", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", "hyperf/context": "~3.1.0", "hyperf/http-server": "~3.1.0", - "hyperf/stringable": "~3.1.0", "hyperf/dispatcher": "~3.1.0", - "hyperf/database": "~3.1.0", "hyperf/contract": "~3.1.0", "hyperf/signal": "~3.1.0", "hyperf/engine": "^2.1", - "hyperf/db-connection": "~3.1.0", "hyperf/framework": "~3.1.0", "friendsofhyperf/pretty-console": "~3.1.0", "friendsofhyperf/command-signals": "~3.1.0", diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index f07b7e70f..084b493fe 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -5,21 +5,20 @@ namespace Hypervel\Foundation; use Closure; -use Hyperf\Collection\Arr; use Hyperf\Di\Definition\DefinitionSourceInterface; -use Hyperf\Macroable\Macroable; use Hypervel\Container\Container; use Hypervel\Container\DefinitionSourceFactory; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Events\LocaleUpdated; use Hypervel\HttpMessage\Exceptions\HttpException; use Hypervel\HttpMessage\Exceptions\NotFoundHttpException; +use Hypervel\Support\Arr; use Hypervel\Support\Environment; use Hypervel\Support\ServiceProvider; +use Hypervel\Support\Traits\Macroable; use Psr\Container\ContainerInterface; use RuntimeException; -use function Hyperf\Collection\data_get; use function Hypervel\Filesystem\join_paths; class Application extends Container implements ApplicationContract @@ -550,47 +549,47 @@ protected function registerCoreContainerAliases(): void 'app', \Hyperf\Di\Container::class, \Hyperf\Contract\ContainerInterface::class, - \Hypervel\Container\Contracts\Container::class, + \Hypervel\Contracts\Container\Container::class, \Hypervel\Container\Container::class, - \Hypervel\Foundation\Contracts\Application::class, + \Hypervel\Contracts\Foundation\Application::class, \Hypervel\Foundation\Application::class, ], - \Hypervel\Foundation\Console\Contracts\Kernel::class => ['artisan'], + \Hypervel\Contracts\Console\Kernel::class => ['artisan'], \Hyperf\Contract\ConfigInterface::class => [ 'config', - \Hypervel\Config\Contracts\Repository::class, + \Hypervel\Contracts\Config\Repository::class, ], \Psr\EventDispatcher\EventDispatcherInterface::class => [ 'events', - \Hypervel\Event\Contracts\Dispatcher::class, + \Hypervel\Contracts\Event\Dispatcher::class, ], \Hyperf\HttpServer\Router\DispatcherFactory::class => ['router'], \Psr\Log\LoggerInterface::class => [ 'log', \Hypervel\Log\LogManager::class, ], - \Hypervel\Encryption\Contracts\Encrypter::class => [ + \Hypervel\Contracts\Encryption\Encrypter::class => [ 'encrypter', \Hypervel\Encryption\Encrypter::class, ], - \Hypervel\Cache\Contracts\Factory::class => [ + \Hypervel\Contracts\Cache\Factory::class => [ 'cache', \Hypervel\Cache\CacheManager::class, ], - \Hypervel\Cache\Contracts\Store::class => [ + \Hypervel\Contracts\Cache\Store::class => [ 'cache.store', \Hypervel\Cache\Repository::class, ], \Hypervel\Filesystem\Filesystem::class => ['files'], - \Hypervel\Filesystem\Contracts\Factory::class => [ + \Hypervel\Contracts\Filesystem\Factory::class => [ 'filesystem', \Hypervel\Filesystem\FilesystemManager::class, ], - \Hypervel\Translation\Contracts\Loader::class => [ + \Hypervel\Contracts\Translation\Loader::class => [ 'translator.loader', \Hyperf\Contract\TranslatorLoaderInterface::class, ], - \Hypervel\Translation\Contracts\Translator::class => [ + \Hypervel\Contracts\Translation\Translator::class => [ 'translator', \Hyperf\Contract\TranslatorInterface::class, ], @@ -598,23 +597,23 @@ protected function registerCoreContainerAliases(): void 'request', \Hyperf\HttpServer\Contract\RequestInterface::class, \Hyperf\HttpServer\Request::class, - \Hypervel\Http\Contracts\RequestContract::class, + \Hypervel\Contracts\Http\Request::class, ], - \Hypervel\Http\Contracts\ResponseContract::class => [ + \Hypervel\Contracts\Http\Response::class => [ 'response', \Hyperf\HttpServer\Contract\ResponseInterface::class, \Hyperf\HttpServer\Response::class, ], - \Hyperf\DbConnection\Db::class => ['db'], + \Hypervel\Database\DatabaseManager::class => ['db'], \Hypervel\Database\Schema\SchemaProxy::class => ['db.schema'], - \Hypervel\Auth\Contracts\Factory::class => [ + \Hypervel\Contracts\Auth\Factory::class => [ 'auth', \Hypervel\Auth\AuthManager::class, ], - \Hypervel\Auth\Contracts\Guard::class => [ + \Hypervel\Contracts\Auth\Guard::class => [ 'auth.driver', ], - \Hypervel\Hashing\Contracts\Hasher::class => ['hash'], + \Hypervel\Contracts\Hashing\Hasher::class => ['hash'], \Hypervel\Cookie\CookieManager::class => ['cookie'], \Hypervel\JWT\Contracts\ManagerContract::class => [ 'jwt', @@ -622,39 +621,39 @@ protected function registerCoreContainerAliases(): void ], \Hyperf\Redis\Redis::class => ['redis'], \Hypervel\Router\Router::class => ['router'], - \Hypervel\Router\Contracts\UrlGenerator::class => [ + \Hypervel\Contracts\Router\UrlGenerator::class => [ 'url', \Hypervel\Router\UrlGenerator::class, ], \Hyperf\ViewEngine\Contract\FactoryInterface::class => ['view'], \Hyperf\ViewEngine\Compiler\CompilerInterface::class => ['blade.compiler'], - \Hypervel\Session\Contracts\Factory::class => [ + \Hypervel\Contracts\Session\Factory::class => [ 'session', \Hypervel\Session\SessionManager::class, ], - \Hypervel\Session\Contracts\Session::class => ['session.store'], - \Hypervel\Mail\Contracts\Factory::class => [ + \Hypervel\Contracts\Session\Session::class => ['session.store'], + \Hypervel\Contracts\Mail\Factory::class => [ 'mail.manager', \Hypervel\Mail\MailManager::class, ], - \Hypervel\Mail\Contracts\Mailer::class => ['mailer'], - \Hypervel\Notifications\Contracts\Dispatcher::class => [ - \Hypervel\Notifications\Contracts\Factory::class, + \Hypervel\Contracts\Mail\Mailer::class => ['mailer'], + \Hypervel\Contracts\Notifications\Dispatcher::class => [ + \Hypervel\Contracts\Notifications\Factory::class, ], - \Hypervel\Bus\Contracts\Dispatcher::class => [ - \Hypervel\Bus\Contracts\QueueingDispatcher::class, + \Hypervel\Contracts\Bus\Dispatcher::class => [ + \Hypervel\Contracts\Bus\QueueingDispatcher::class, \Hypervel\Bus\Dispatcher::class, ], - \Hypervel\Queue\Contracts\Factory::class => [ + \Hypervel\Contracts\Queue\Factory::class => [ 'queue', - \Hypervel\Queue\Contracts\Monitor::class, + \Hypervel\Contracts\Queue\Monitor::class, \Hypervel\Queue\QueueManager::class, ], - \Hypervel\Queue\Contracts\Queue::class => ['queue.connection'], + \Hypervel\Contracts\Queue\Queue::class => ['queue.connection'], \Hypervel\Queue\Worker::class => ['queue.worker'], \Hypervel\Queue\Listener::class => ['queue.listener'], \Hypervel\Queue\Failed\FailedJobProviderInterface::class => ['queue.failer'], - \Hypervel\Validation\Contracts\Factory::class => ['validator'], + \Hypervel\Contracts\Validation\Factory::class => ['validator'], \Hypervel\Validation\DatabasePresenceVerifierInterface::class => ['validation.presence'], ] as $key => $aliases) { foreach ($aliases as $alias) { diff --git a/src/foundation/src/Auth/User.php b/src/foundation/src/Auth/User.php index 44999583a..b7044a71a 100644 --- a/src/foundation/src/Auth/User.php +++ b/src/foundation/src/Auth/User.php @@ -6,8 +6,8 @@ use Hypervel\Auth\Access\Authorizable; use Hypervel\Auth\Authenticatable; -use Hypervel\Auth\Contracts\Authenticatable as AuthenticatableContract; -use Hypervel\Auth\Contracts\Authorizable as AuthorizableContract; +use Hypervel\Contracts\Auth\Access\Authorizable as AuthorizableContract; +use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract; use Hypervel\Database\Eloquent\Model; class User extends Model implements AuthenticatableContract, AuthorizableContract diff --git a/src/foundation/src/Bootstrap/BootProviders.php b/src/foundation/src/Bootstrap/BootProviders.php index 538e7d29c..ea2f1c993 100644 --- a/src/foundation/src/Bootstrap/BootProviders.php +++ b/src/foundation/src/Bootstrap/BootProviders.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Bootstrap; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; class BootProviders { diff --git a/src/foundation/src/Bootstrap/RegisterFacades.php b/src/foundation/src/Bootstrap/RegisterFacades.php index 9c9eda920..ee1cd658d 100644 --- a/src/foundation/src/Bootstrap/RegisterFacades.php +++ b/src/foundation/src/Bootstrap/RegisterFacades.php @@ -4,9 +4,9 @@ namespace Hypervel\Foundation\Bootstrap; -use Hyperf\Collection\Arr; use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Support\Arr; use Hypervel\Support\Composer; use Hypervel\Support\Facades\Facade; use Throwable; diff --git a/src/foundation/src/Bootstrap/RegisterProviders.php b/src/foundation/src/Bootstrap/RegisterProviders.php index 4e938b64d..f3b81251d 100644 --- a/src/foundation/src/Bootstrap/RegisterProviders.php +++ b/src/foundation/src/Bootstrap/RegisterProviders.php @@ -4,10 +4,10 @@ namespace Hypervel\Foundation\Bootstrap; -use Hyperf\Collection\Arr; use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Providers\FoundationServiceProvider; +use Hypervel\Support\Arr; use Hypervel\Support\Composer; use Throwable; diff --git a/src/foundation/src/Console/Commands/VendorPublishCommand.php b/src/foundation/src/Console/Commands/VendorPublishCommand.php index c2c53814c..1d729ad1d 100644 --- a/src/foundation/src/Console/Commands/VendorPublishCommand.php +++ b/src/foundation/src/Console/Commands/VendorPublishCommand.php @@ -4,14 +4,14 @@ namespace Hypervel\Foundation\Console\Commands; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\Contract\ContainerInterface; -use Hyperf\Stringable\Str; use Hyperf\Support\Composer; use Hyperf\Support\Filesystem\Filesystem; use Hypervel\Console\Command; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Hypervel\Support\ServiceProvider; +use Hypervel\Support\Str; class VendorPublishCommand extends Command { diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index bc34fc5cb..51da47a2f 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -6,29 +6,26 @@ use Closure; use Exception; -use Hyperf\Collection\Arr; use Hyperf\Command\Annotation\Command as AnnotationCommand; use Hyperf\Contract\ApplicationInterface; use Hyperf\Contract\ConfigInterface; use Hyperf\Di\Annotation\AnnotationCollector; use Hyperf\Di\ReflectionManager; use Hyperf\Framework\Event\BootApplication; -use Hyperf\Stringable\Str; use Hypervel\Console\Application as ConsoleApplication; use Hypervel\Console\ClosureCommand; -use Hypervel\Console\Contracts\Application as ApplicationContract; use Hypervel\Console\HasPendingCommand; use Hypervel\Console\Scheduling\Schedule; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; -use Hypervel\Foundation\Contracts\Application as ContainerContract; +use Hypervel\Contracts\Console\Application as ApplicationContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Foundation\Application as ContainerContract; +use Hypervel\Support\Arr; +use Hypervel\Support\Str; use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function Hyperf\Tappable\tap; -use function Hypervel\Support\env; - class Kernel implements KernelContract { use HasPendingCommand; diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 2a3e8ef46..33c7bc270 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -6,12 +6,10 @@ use Closure; use Exception; -use Hyperf\Collection\Arr; use Hyperf\Context\Context; use Hyperf\Contract\MessageBag as MessageBagContract; use Hyperf\Contract\MessageProvider; use Hyperf\Contract\SessionInterface; -use Hyperf\Database\Model\ModelNotFoundException; use Hyperf\ExceptionHandler\ExceptionHandler; use Hyperf\HttpMessage\Base\Response as BaseResponse; use Hyperf\HttpMessage\Exception\HttpException as HyperfHttpException; @@ -20,20 +18,22 @@ use Hyperf\ViewEngine\ViewErrorBag; use Hypervel\Auth\Access\AuthorizationException; use Hypervel\Auth\AuthenticationException; -use Hypervel\Foundation\Contracts\Application as Container; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionRenderer; -use Hypervel\Foundation\Exceptions\Contracts\ShouldntReport; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Debug\ShouldntReport; +use Hypervel\Contracts\Foundation\Application as Container; +use Hypervel\Contracts\Foundation\ExceptionRenderer; +use Hypervel\Contracts\Http\Response as ResponseContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Session\Session as SessionContract; +use Hypervel\Contracts\Support\Responsable; +use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Http\Request; use Hypervel\HttpMessage\Exceptions\AccessDeniedHttpException; use Hypervel\HttpMessage\Exceptions\HttpException; use Hypervel\HttpMessage\Exceptions\HttpResponseException; use Hypervel\HttpMessage\Exceptions\NotFoundHttpException; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; -use Hypervel\Session\Contracts\Session as SessionContract; use Hypervel\Session\TokenMismatchException; -use Hypervel\Support\Contracts\Responsable; +use Hypervel\Support\Arr; use Hypervel\Support\Facades\Auth; use Hypervel\Support\Reflector; use Hypervel\Support\Traits\ReflectsClosures; diff --git a/src/foundation/src/Exceptions/RegisterErrorViewPaths.php b/src/foundation/src/Exceptions/RegisterErrorViewPaths.php index eca51e058..630ffa229 100644 --- a/src/foundation/src/Exceptions/RegisterErrorViewPaths.php +++ b/src/foundation/src/Exceptions/RegisterErrorViewPaths.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Exceptions; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; use Hypervel\Support\Facades\View; class RegisterErrorViewPaths diff --git a/src/foundation/src/Exceptions/WhoopsErrorRenderer.php b/src/foundation/src/Exceptions/WhoopsErrorRenderer.php index 9c1fb3e6d..58aa01aa9 100644 --- a/src/foundation/src/Exceptions/WhoopsErrorRenderer.php +++ b/src/foundation/src/Exceptions/WhoopsErrorRenderer.php @@ -6,8 +6,8 @@ use Hyperf\Context\RequestContext; use Hypervel\Context\ApplicationContext; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionRenderer; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Foundation\ExceptionRenderer; +use Hypervel\Contracts\Session\Session as SessionContract; use Throwable; use Whoops\Handler\PrettyPageHandler; use Whoops\Run; diff --git a/src/foundation/src/Http/Casts/AsEnumCollection.php b/src/foundation/src/Http/Casts/AsEnumCollection.php index 5fae44356..066b96734 100644 --- a/src/foundation/src/Http/Casts/AsEnumCollection.php +++ b/src/foundation/src/Http/Casts/AsEnumCollection.php @@ -5,9 +5,9 @@ namespace Hypervel\Foundation\Http\Casts; use BackedEnum; -use Hyperf\Collection\Collection; use Hypervel\Foundation\Http\Contracts\Castable; use Hypervel\Foundation\Http\Contracts\CastInputs; +use Hypervel\Support\Collection; use function Hypervel\Support\enum_value; diff --git a/src/foundation/src/Http/FormRequest.php b/src/foundation/src/Http/FormRequest.php index 42a50580a..f96192b8b 100644 --- a/src/foundation/src/Http/FormRequest.php +++ b/src/foundation/src/Http/FormRequest.php @@ -4,15 +4,15 @@ namespace Hypervel\Foundation\Http; -use Hyperf\Collection\Arr; use Hyperf\Context\Context; use Hyperf\Context\ResponseContext; use Hypervel\Auth\Access\AuthorizationException; +use Hypervel\Contracts\Validation\Factory as ValidationFactory; +use Hypervel\Contracts\Validation\ValidatesWhenResolved; +use Hypervel\Contracts\Validation\Validator; use Hypervel\Foundation\Http\Traits\HasCasts; use Hypervel\Http\Request; -use Hypervel\Validation\Contracts\Factory as ValidationFactory; -use Hypervel\Validation\Contracts\ValidatesWhenResolved; -use Hypervel\Validation\Contracts\Validator; +use Hypervel\Support\Arr; use Hypervel\Validation\ValidatesWhenResolvedTrait; use Hypervel\Validation\ValidationException; use Psr\Container\ContainerInterface; diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index 383a2121d..498e27f98 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -15,7 +15,7 @@ use Hyperf\HttpServer\Event\RequestTerminated; use Hyperf\HttpServer\Server as HyperfServer; use Hyperf\Support\SafeCaller; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Foundation\Exceptions\Handler as ExceptionHandler; use Hypervel\Foundation\Http\Contracts\MiddlewareContract; use Hypervel\Foundation\Http\Traits\HasMiddleware; diff --git a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php index 8d094ebf5..35fc61937 100644 --- a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php +++ b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Http\Middleware\Concerns; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; use Psr\Http\Message\ServerRequestInterface; trait ExcludesPaths diff --git a/src/foundation/src/Http/Middleware/VerifyCsrfToken.php b/src/foundation/src/Http/Middleware/VerifyCsrfToken.php index 6fb2e13db..e08acb1f7 100644 --- a/src/foundation/src/Http/Middleware/VerifyCsrfToken.php +++ b/src/foundation/src/Http/Middleware/VerifyCsrfToken.php @@ -4,15 +4,15 @@ namespace Hypervel\Foundation\Http\Middleware; -use Hyperf\Collection\Arr; use Hyperf\Contract\ConfigInterface; use Hyperf\HttpServer\Request; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Cookie\Cookie; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; use Hypervel\Foundation\Http\Middleware\Concerns\ExcludesPaths; -use Hypervel\Session\Contracts\Session as SessionContract; use Hypervel\Session\TokenMismatchException; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Support\Arr; +use Hypervel\Support\InteractsWithTime; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; diff --git a/src/foundation/src/Http/Traits/HasCasts.php b/src/foundation/src/Http/Traits/HasCasts.php index cd83c4e37..c1e29f578 100644 --- a/src/foundation/src/Http/Traits/HasCasts.php +++ b/src/foundation/src/Http/Traits/HasCasts.php @@ -4,11 +4,11 @@ namespace Hypervel\Foundation\Http\Traits; +use BackedEnum; use Carbon\Carbon; use Carbon\CarbonInterface; use DateTimeInterface; -use Hyperf\Database\Exception\InvalidCastException; -use Hyperf\Database\Model\EnumCollector; +use Hypervel\Database\Eloquent\InvalidCastException; use Hypervel\Foundation\Http\Contracts\Castable; use Hypervel\Foundation\Http\Contracts\CastInputs; use Hypervel\Support\Collection; @@ -223,7 +223,7 @@ public function getDataObjectCastableInputValue(string $key, mixed $value): mixe $castType = $this->getCasts()[$key]; if (! is_array($value)) { - throw new InvalidCastException(static::class, $key, $castType); + throw new InvalidCastException($this, $key, $castType); } // Check if the class has make static method (provided by DataObject) @@ -241,7 +241,9 @@ public function getDataObjectCastableInputValue(string $key, mixed $value): mixe */ protected function getEnumCaseFromValue(string $enumClass, int|string $value): UnitEnum { - return EnumCollector::getEnumCaseFromValue($enumClass, $value); + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($value) + : constant($enumClass . '::' . $value); } /** @@ -301,7 +303,7 @@ protected function isClassCastable(string $key): bool return true; } - throw new InvalidCastException(static::class, $key, $castType); + throw new InvalidCastException($this, $key, $castType); } /** diff --git a/src/foundation/src/Http/WebsocketKernel.php b/src/foundation/src/Http/WebsocketKernel.php index c27b57c92..695d2703b 100644 --- a/src/foundation/src/Http/WebsocketKernel.php +++ b/src/foundation/src/Http/WebsocketKernel.php @@ -18,7 +18,7 @@ use Hyperf\WebSocketServer\Exception\WebSocketHandShakeException; use Hyperf\WebSocketServer\Security; use Hyperf\WebSocketServer\Server as WebSocketServer; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Foundation\Exceptions\Handler as ExceptionHandler; use Hypervel\Foundation\Http\Contracts\MiddlewareContract; use Hypervel\Foundation\Http\Traits\HasMiddleware; diff --git a/src/foundation/src/Listeners/ReloadDotenvAndConfig.php b/src/foundation/src/Listeners/ReloadDotenvAndConfig.php index 60cd7e3c9..a294e8ad1 100644 --- a/src/foundation/src/Listeners/ReloadDotenvAndConfig.php +++ b/src/foundation/src/Listeners/ReloadDotenvAndConfig.php @@ -8,7 +8,7 @@ use Hyperf\Event\Contract\ListenerInterface; use Hyperf\Framework\Event\BeforeWorkerStart; use Hyperf\Support\DotenvManager; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; class ReloadDotenvAndConfig implements ListenerInterface { @@ -65,7 +65,7 @@ protected function setConfigCallback(): void { $this->container->get(ConfigInterface::class) ->afterSettingCallback(function (array $values) { - static::$modifiedItems = array_merge( + static::$modifiedItems = array_replace( static::$modifiedItems, $values ); diff --git a/src/foundation/src/Listeners/SetProcessTitle.php b/src/foundation/src/Listeners/SetProcessTitle.php index 4c94a9300..c916a3844 100644 --- a/src/foundation/src/Listeners/SetProcessTitle.php +++ b/src/foundation/src/Listeners/SetProcessTitle.php @@ -6,7 +6,7 @@ use Hyperf\Contract\ConfigInterface; use Hyperf\Server\Listener\InitProcessTitleListener; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; class SetProcessTitle extends InitProcessTitleListener { diff --git a/src/foundation/src/Providers/FormRequestServiceProvider.php b/src/foundation/src/Providers/FormRequestServiceProvider.php index 13629a2c6..45f326d87 100644 --- a/src/foundation/src/Providers/FormRequestServiceProvider.php +++ b/src/foundation/src/Providers/FormRequestServiceProvider.php @@ -4,9 +4,9 @@ namespace Hypervel\Foundation\Providers; +use Hypervel\Contracts\Validation\ValidatesWhenResolved; use Hypervel\Http\RouteDependency; use Hypervel\Support\ServiceProvider; -use Hypervel\Validation\Contracts\ValidatesWhenResolved; class FormRequestServiceProvider extends ServiceProvider { diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php index 172d69148..acbdc1fc0 100644 --- a/src/foundation/src/Providers/FoundationServiceProvider.php +++ b/src/foundation/src/Providers/FoundationServiceProvider.php @@ -7,20 +7,20 @@ use Hyperf\Command\Event\FailToHandle; use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\StdoutLoggerInterface; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Grammar; use Hyperf\HttpServer\MiddlewareManager; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; -use Hypervel\Container\Contracts\Container; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Event\Dispatcher; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Grammar; use Hypervel\Foundation\Console\CliDumper; use Hypervel\Foundation\Console\Kernel as ConsoleKernel; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; use Hypervel\Foundation\Http\Contracts\MiddlewareContract; use Hypervel\Foundation\Http\HtmlDumper; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; use Hypervel\Support\ServiceProvider; use Hypervel\Support\Uri; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/src/foundation/src/Testing/AttributeParser.php b/src/foundation/src/Testing/AttributeParser.php new file mode 100644 index 000000000..5664bc305 --- /dev/null +++ b/src/foundation/src/Testing/AttributeParser.php @@ -0,0 +1,113 @@ + + */ + public static function forClass(string $className): array + { + $attributes = []; + $reflection = new ReflectionClass($className); + + foreach ($reflection->getAttributes() as $attribute) { + if (! static::validAttribute($attribute->getName())) { + continue; + } + + [$name, $instance] = static::resolveAttribute($attribute); + + if ($name !== null && $instance !== null) { + $attributes[] = ['key' => $name, 'instance' => $instance]; + } + } + + $parent = $reflection->getParentClass(); + + if ($parent !== false && $parent->isSubclassOf(TestCase::class)) { + $attributes = [...static::forClass($parent->getName()), ...$attributes]; + } + + return $attributes; + } + + /** + * Parse attributes for a method. + * + * @param class-string $className + * @return array + */ + public static function forMethod(string $className, string $methodName): array + { + $attributes = []; + + foreach ((new ReflectionMethod($className, $methodName))->getAttributes() as $attribute) { + if (! static::validAttribute($attribute->getName())) { + continue; + } + + [$name, $instance] = static::resolveAttribute($attribute); + + if ($name !== null && $instance !== null) { + $attributes[] = ['key' => $name, 'instance' => $instance]; + } + } + + return $attributes; + } + + /** + * Validate if a class is a valid testing attribute. + * + * @param class-string|object $class + */ + public static function validAttribute(object|string $class): bool + { + if (\is_string($class) && ! class_exists($class)) { + return false; + } + + $implements = class_implements($class); + + return isset($implements[TestingFeature::class]) + || isset($implements[Resolvable::class]); + } + + /** + * Resolve the given attribute. + * + * @return array{0: null|class-string, 1: null|object} + */ + protected static function resolveAttribute(ReflectionAttribute $attribute): array + { + /** @var array{0: null|class-string, 1: null|object} */ + return rescue(static function () use ($attribute): array { // @phpstan-ignore argument.unresolvableType + $instance = isset(class_implements($attribute->getName())[Resolvable::class]) + ? transform($attribute->newInstance(), static fn (Resolvable $instance) => $instance->resolve()) + : $attribute->newInstance(); + + if ($instance === null) { + return [null, null]; + } + + return [$instance::class, $instance]; + }, [null, null], false); + } +} diff --git a/src/foundation/src/Testing/Attributes/Define.php b/src/foundation/src/Testing/Attributes/Define.php new file mode 100644 index 000000000..1d55570b2 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/Define.php @@ -0,0 +1,37 @@ +group)) { + 'env' => new DefineEnvironment($this->method), + 'db' => new DefineDatabase($this->method), + 'route' => new DefineRoute($this->method), + default => null, + }; + } +} diff --git a/src/foundation/src/Testing/Attributes/DefineDatabase.php b/src/foundation/src/Testing/Attributes/DefineDatabase.php new file mode 100644 index 000000000..897e1bff1 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/DefineDatabase.php @@ -0,0 +1,63 @@ +):void $action + */ + public function handle(ApplicationContract $app, Closure $action): ?Closure + { + $resolver = function () use ($app, $action) { + \call_user_func($action, $this->method, [$app]); + }; + + if ($this->defer === false) { + $resolver(); + + return null; + } + + return $resolver; + } +} diff --git a/src/foundation/src/Testing/Attributes/DefineEnvironment.php b/src/foundation/src/Testing/Attributes/DefineEnvironment.php new file mode 100644 index 000000000..1b3673bb7 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/DefineEnvironment.php @@ -0,0 +1,34 @@ +):void $action + */ + public function handle(ApplicationContract $app, Closure $action): mixed + { + \call_user_func($action, $this->method, [$app]); + + return null; + } +} diff --git a/src/foundation/src/Testing/Attributes/DefineRoute.php b/src/foundation/src/Testing/Attributes/DefineRoute.php new file mode 100644 index 000000000..c3763aad3 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/DefineRoute.php @@ -0,0 +1,37 @@ +):void $action + */ + public function handle(ApplicationContract $app, Closure $action): mixed + { + $router = $app->get(Router::class); + + \call_user_func($action, $this->method, [$router]); + + return null; + } +} diff --git a/src/foundation/src/Testing/Attributes/RequiresDatabase.php b/src/foundation/src/Testing/Attributes/RequiresDatabase.php new file mode 100644 index 000000000..74fabf9b8 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/RequiresDatabase.php @@ -0,0 +1,103 @@ +|string $driver The required database driver(s) + * @param null|string $versionRequirement Optional version requirement (e.g., ">=8.0") + * @param null|string $connection Optional connection name to check + * @param null|bool $default Whether to check the default connection + */ + public function __construct( + public readonly array|string $driver, + public readonly ?string $versionRequirement = null, + public readonly ?string $connection = null, + ?bool $default = null + ) { + if ($connection === null && is_string($driver)) { + $default = true; + } + + $this->default = $default; + + if (is_array($driver) && $default === true) { + throw new InvalidArgumentException('Unable to validate default connection when given an array of database drivers'); + } + } + + /** + * Handle the attribute. + * + * @param Closure(string, array):void $action + */ + public function handle(ApplicationContract $app, Closure $action): mixed + { + $connection = DB::connection($this->connection); + + if ( + ($this->default ?? false) === true + && is_string($this->driver) + && $connection->getDriverName() !== $this->driver + ) { + call_user_func($action, 'markTestSkipped', [sprintf('Requires %s to be configured for "%s" database connection', $this->driver, $connection->getName())]); + + return null; + } + + $drivers = (new Collection( + Arr::wrap($this->driver) + ))->filter(fn ($driver) => $driver === $connection->getDriverName()); + + if ($drivers->isEmpty()) { + call_user_func( + $action, + 'markTestSkipped', + [sprintf('Requires [%s] to be configured for "%s" database connection', Arr::join(Arr::wrap($this->driver), '/'), $connection->getName())] + ); + + return null; + } + + if ( + is_string($this->driver) + && $this->versionRequirement !== null + && preg_match('/(?P[<>=!]{0,2})\s*(?P[\d\.-]+(dev|(RC|alpha|beta)[\d\.])?)[ \t]*\r?$/m', $this->versionRequirement, $matches) + ) { + if (empty($matches['operator'])) { + $matches['operator'] = '>='; + } + + if (! version_compare($connection->getServerVersion(), $matches['version'], $matches['operator'])) { + call_user_func( + $action, + 'markTestSkipped', + [sprintf('Requires %s:%s to be configured for "%s" database connection', $this->driver, $this->versionRequirement, $connection->getName())] + ); + } + } + + return null; + } +} diff --git a/src/foundation/src/Testing/Attributes/RequiresEnv.php b/src/foundation/src/Testing/Attributes/RequiresEnv.php new file mode 100644 index 000000000..05068fcae --- /dev/null +++ b/src/foundation/src/Testing/Attributes/RequiresEnv.php @@ -0,0 +1,39 @@ +):void $action + */ + public function handle(ApplicationContract $app, Closure $action): mixed + { + $message = $this->message ?? "Missing required environment variable `{$this->key}`"; + + if (env($this->key) === null) { + \call_user_func($action, 'markTestSkipped', [$message]); + } + + return null; + } +} diff --git a/src/foundation/src/Testing/Attributes/ResetRefreshDatabaseState.php b/src/foundation/src/Testing/Attributes/ResetRefreshDatabaseState.php new file mode 100644 index 000000000..957370091 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/ResetRefreshDatabaseState.php @@ -0,0 +1,43 @@ +get('config')->set($this->key, $this->value); + + return null; + } +} diff --git a/src/foundation/src/Testing/Attributes/WithMigration.php b/src/foundation/src/Testing/Attributes/WithMigration.php new file mode 100644 index 000000000..c58d1d817 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/WithMigration.php @@ -0,0 +1,44 @@ + + */ + public readonly array $paths; + + /** + * @param string ...$paths Migration paths to load + */ + public function __construct(string ...$paths) + { + $this->paths = $paths; + } + + /** + * Handle the attribute. + */ + public function __invoke(ApplicationContract $app): mixed + { + $app->afterResolving(Migrator::class, function (Migrator $migrator) { + foreach ($this->paths as $path) { + $migrator->path($path); + } + }); + + return null; + } +} diff --git a/src/foundation/src/Testing/Concerns/HandlesAttributes.php b/src/foundation/src/Testing/Concerns/HandlesAttributes.php new file mode 100644 index 000000000..4c7206432 --- /dev/null +++ b/src/foundation/src/Testing/Concerns/HandlesAttributes.php @@ -0,0 +1,56 @@ +resolvePhpUnitAttributes() + ->filter(static fn ($attributes, string $key) => $key === $attribute && ! empty($attributes)) + ->flatten() + ->map(function ($instance) use ($app) { + if ($instance instanceof Invokable) { + return $instance($app); + } + + if ($instance instanceof Actionable) { + return $instance->handle($app, fn ($method, $parameters) => $this->{$method}(...$parameters)); + } + + return null; + }) + ->filter() + ->values(); + + return new FeaturesCollection($attributes); + } + + /** + * Resolve PHPUnit method attributes. + * + * @return \Hypervel\Support\Collection> + */ + abstract protected function resolvePhpUnitAttributes(): Collection; +} diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php b/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php index 5033ec9d0..7e267eff8 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAuthentication.php @@ -4,8 +4,8 @@ namespace Hypervel\Foundation\Testing\Concerns; -use Hypervel\Auth\Contracts\Authenticatable as UserContract; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; +use Hypervel\Contracts\Auth\Authenticatable as UserContract; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; trait InteractsWithAuthentication { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php index 279e0d183..d83cca7cf 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Foundation\Testing\PendingCommand; trait InteractsWithConsole diff --git a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php index 79e7c4842..338b15026 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php @@ -6,9 +6,9 @@ use Closure; use Hyperf\Contract\ApplicationInterface; -use Hyperf\Database\ConnectionResolverInterface; use Hyperf\Dispatcher\HttpDispatcher; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Hypervel\Foundation\Testing\Dispatcher\HttpDispatcher as TestingHttpDispatcher; use Mockery; @@ -90,9 +90,20 @@ protected function refreshApplication(): void /* @phpstan-ignore-next-line */ $this->app->bind(HttpDispatcher::class, TestingHttpDispatcher::class); $this->app->bind(ConnectionResolverInterface::class, DatabaseConnectionResolver::class); + + $this->defineEnvironment($this->app); + $this->app->get(ApplicationInterface::class); } + /** + * Define environment setup. + */ + protected function defineEnvironment(ApplicationContract $app): void + { + // Override in subclass. + } + protected function createApplication(): ApplicationContract { return require BASE_PATH . '/bootstrap/app.php'; diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index 1396b606a..7cc88a35d 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -4,15 +4,15 @@ namespace Hypervel\Foundation\Testing\Concerns; -use Hyperf\Collection\Arr; -use Hyperf\Contract\Jsonable; -use Hyperf\Database\Events\QueryExecuted; -use Hyperf\Database\Model\Model; -use Hyperf\Database\Model\SoftDeletes; +use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\Events\QueryExecuted; use Hypervel\Foundation\Testing\Constraints\CountInDatabase; use Hypervel\Foundation\Testing\Constraints\HasInDatabase; use Hypervel\Foundation\Testing\Constraints\NotSoftDeletedInDatabase; use Hypervel\Foundation\Testing\Constraints\SoftDeletedInDatabase; +use Hypervel\Support\Arr; use Hypervel\Support\Facades\DB; use PHPUnit\Framework\Constraint\LogicalNot as ReverseConstraint; @@ -21,7 +21,7 @@ trait InteractsWithDatabase /** * Assert that a given where condition exists in the database. * - * @param \Hyperf\Database\Model\Model|string $table + * @param \Hypervel\Database\Eloquent\Model|string $table * @param null|string $connection * @return $this */ @@ -38,7 +38,7 @@ protected function assertDatabaseHas($table, array $data, $connection = null) /** * Assert that a given where condition does not exist in the database. * - * @param \Hyperf\Database\Model\Model|string $table + * @param \Hypervel\Database\Eloquent\Model|string $table * @param null|string $connection * @return $this */ @@ -56,7 +56,7 @@ protected function assertDatabaseMissing($table, array $data, $connection = null /** * Assert the count of table entries. * - * @param \Hyperf\Database\Model\Model|string $table + * @param \Hypervel\Database\Eloquent\Model|string $table * @param null|string $connection * @return $this */ @@ -73,7 +73,7 @@ protected function assertDatabaseCount($table, int $count, $connection = null) /** * Assert that the given table has no entries. * - * @param \Hyperf\Database\Model\Model|string $table + * @param \Hypervel\Database\Eloquent\Model|string $table * @param null|string $connection * @return $this */ @@ -90,7 +90,7 @@ protected function assertDatabaseEmpty($table, $connection = null) /** * Assert the given record has been "soft deleted". * - * @param \Hyperf\Database\Model\Model|string $table + * @param \Hypervel\Database\Eloquent\Model|string $table * @param null|string $connection * @param null|string $deletedAtColumn * @return $this @@ -121,7 +121,7 @@ protected function assertSoftDeleted($table, array $data = [], $connection = nul /** * Assert the given record has not been "soft deleted". * - * @param \Hyperf\Database\Model\Model|string $table + * @param \Hypervel\Database\Eloquent\Model|string $table * @param null|string $connection * @param null|string $deletedAtColumn * @return $this @@ -152,7 +152,7 @@ protected function assertNotSoftDeleted($table, array $data = [], $connection = /** * Assert the given model exists in the database. * - * @param \Hyperf\Database\Model\Model $model + * @param \Hypervel\Database\Eloquent\Model $model * @return $this */ protected function assertModelExists($model) @@ -167,7 +167,7 @@ protected function assertModelExists($model) /** * Assert the given model does not exist in the database. * - * @param \Hyperf\Database\Model\Model $model + * @param \Hypervel\Database\Eloquent\Model $model * @return $this */ protected function assertModelMissing($model) @@ -225,7 +225,7 @@ protected function isSoftDeletableModel($model) * Cast a JSON string to a database compatible type. * * @param array|object|string $value - * @return \Hyperf\Database\Query\Expression + * @return \Hypervel\Database\Query\Expression */ public function castAsJson($value) { @@ -247,7 +247,7 @@ public function castAsJson($value) * * @param null|string $connection * @param null|string $table - * @return \Hyperf\DbConnection\Connection + * @return \Hypervel\Database\Connection */ protected function getConnection($connection = null, $table = null) { @@ -257,7 +257,7 @@ protected function getConnection($connection = null, $table = null) /** * Get the table name from the given model or string. * - * @param \Hyperf\Database\Model\Model|string $table + * @param \Hypervel\Database\Eloquent\Model|string $table * @return string */ protected function getTable($table) @@ -268,7 +268,7 @@ protected function getTable($table) /** * Get the table connection specified in the given model. * - * @param \Hyperf\Database\Model\Model|string $table + * @param \Hypervel\Database\Eloquent\Model|string $table * @return null|string */ protected function getTableConnection($table) @@ -291,8 +291,8 @@ protected function getDeletedAtColumn($table, $defaultColumnName = 'deleted_at') /** * Get the model entity from the given model or string. * - * @param \Hyperf\Database\Model\Model|string $table - * @return null|\Hyperf\Database\Model\Model + * @param \Hypervel\Database\Eloquent\Model|string $table + * @return null|\Hypervel\Database\Eloquent\Model */ protected function newModelFor($table) { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithSession.php b/src/foundation/src/Testing/Concerns/InteractsWithSession.php index 588409417..552ff12cf 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithSession.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithSession.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; -use Hypervel\Session\Contracts\Session; +use Hypervel\Contracts\Session\Session; trait InteractsWithSession { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCase.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCase.php new file mode 100644 index 000000000..57150f973 --- /dev/null +++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCase.php @@ -0,0 +1,245 @@ +> + */ + protected static array $cachedTestCaseClassAttributes = []; + + /** + * Cached method attributes by "class:method" key. + * + * @var array> + */ + protected static array $cachedTestCaseMethodAttributes = []; + + /** + * Programmatically added class-level testing features. + * + * @var array + */ + protected static array $testCaseTestingFeatures = []; + + /** + * Programmatically added method-level testing features. + * + * @var array + */ + protected static array $testCaseMethodTestingFeatures = []; + + /** + * Cached traits used by test case. + * + * @var null|array + */ + protected static ?array $cachedTestCaseUses = null; + + /** + * Check if the test case uses a specific trait. + * + * @param class-string $trait + */ + public static function usesTestingConcern(string $trait): bool + { + return isset(static::cachedUsesForTestCase()[$trait]); + } + + /** + * Cache and return traits used by test case. + * + * @return array + */ + public static function cachedUsesForTestCase(): array + { + if (static::$cachedTestCaseUses === null) { + /** @var array $uses */ + $uses = array_flip(class_uses_recursive(static::class)); + static::$cachedTestCaseUses = $uses; + } + + return static::$cachedTestCaseUses; + } + + /** + * Programmatically add a testing feature attribute. + */ + public static function usesTestingFeature(object $attribute, int $flag = Attribute::TARGET_CLASS): void + { + if (! AttributeParser::validAttribute($attribute)) { + return; + } + + $attribute = $attribute instanceof Resolvable ? $attribute->resolve() : $attribute; + + if ($attribute === null) { + return; + } + + if ($flag & Attribute::TARGET_CLASS) { + static::$testCaseTestingFeatures[] = [ + 'key' => $attribute::class, + 'instance' => $attribute, + ]; + } elseif ($flag & Attribute::TARGET_METHOD) { + static::$testCaseMethodTestingFeatures[] = [ + 'key' => $attribute::class, + 'instance' => $attribute, + ]; + } + } + + /** + * Resolve and cache PHPUnit attributes for current test. + * + * @return \Hypervel\Support\Collection> + */ + protected function resolvePhpUnitAttributes(): Collection + { + $className = static::class; + $methodName = $this->name(); + + // Cache class attributes + if (! isset(static::$cachedTestCaseClassAttributes[$className])) { + static::$cachedTestCaseClassAttributes[$className] = AttributeParser::forClass($className); + } + + // Cache method attributes + $cacheKey = "{$className}:{$methodName}"; + if (! isset(static::$cachedTestCaseMethodAttributes[$cacheKey])) { + static::$cachedTestCaseMethodAttributes[$cacheKey] = AttributeParser::forMethod($className, $methodName); + } + + // Merge all sources and group by attribute class + return (new Collection(array_merge( + static::$testCaseTestingFeatures, + static::$cachedTestCaseClassAttributes[$className], + static::$testCaseMethodTestingFeatures, + static::$cachedTestCaseMethodAttributes[$cacheKey], + )))->groupBy('key') + ->map(static fn ($attrs) => $attrs->pluck('instance')); + } + + /** + * Resolve attributes for class (and optionally method) - used by static lifecycle methods. + * + * @param class-string $className + * @return \Hypervel\Support\Collection> + */ + protected static function resolvePhpUnitAttributesForMethod(string $className, ?string $methodName = null): Collection + { + $attributes = array_merge( + static::$testCaseTestingFeatures, + AttributeParser::forClass($className), + ); + + if ($methodName !== null) { + $attributes = array_merge( + $attributes, + static::$testCaseMethodTestingFeatures, + AttributeParser::forMethod($className, $methodName), + ); + } + + return (new Collection($attributes)) + ->groupBy('key') + ->map(static fn ($attrs) => $attrs->pluck('instance')); + } + + /** + * Execute setup lifecycle attributes (Invokable, Actionable, BeforeEach). + */ + protected function setUpTheTestEnvironmentUsingTestCase(): void + { + $attributes = $this->resolvePhpUnitAttributes()->flatten(); + + // Execute Invokable attributes (like WithConfig) + $attributes + ->filter(static fn ($instance) => $instance instanceof Invokable) + ->each(fn ($instance) => $instance($this->app)); + + // Execute Actionable attributes (like DefineEnvironment, DefineRoute, DefineDatabase) + // Some attributes (like DefineDatabase with defer: true) return a Closure + // that must be executed to complete the setup + $attributes + ->filter(static fn ($instance) => $instance instanceof Actionable) + ->each(function ($instance): void { + $result = $instance->handle( + $this->app, + fn ($method, $parameters) => $this->{$method}(...$parameters) + ); + + if ($result instanceof Closure) { + $result(); + } + }); + + // Execute BeforeEach attributes + $attributes + ->filter(static fn ($instance) => $instance instanceof BeforeEach) + ->each(fn ($instance) => $instance->beforeEach($this->app)); + } + + /** + * Execute AfterEach lifecycle attributes. + */ + protected function tearDownTheTestEnvironmentUsingTestCase(): void + { + $this->resolvePhpUnitAttributes() + ->flatten() + ->filter(static fn ($instance) => $instance instanceof AfterEach) + ->each(fn ($instance) => $instance->afterEach($this->app)); + + static::$testCaseMethodTestingFeatures = []; + } + + /** + * Execute BeforeAll lifecycle attributes. + */ + public static function setUpBeforeClassUsingTestCase(): void + { + static::resolvePhpUnitAttributesForMethod(static::class) + ->flatten() + ->filter(static fn ($instance) => $instance instanceof BeforeAll) + ->each(static fn ($instance) => $instance->beforeAll()); + } + + /** + * Execute AfterAll lifecycle attributes and clear caches. + */ + public static function tearDownAfterClassUsingTestCase(): void + { + static::resolvePhpUnitAttributesForMethod(static::class) + ->flatten() + ->filter(static fn ($instance) => $instance instanceof AfterAll) + ->each(static fn ($instance) => $instance->afterAll()); + + static::$testCaseTestingFeatures = []; + static::$cachedTestCaseClassAttributes = []; + static::$cachedTestCaseMethodAttributes = []; + static::$cachedTestCaseUses = null; + } +} diff --git a/src/foundation/src/Testing/Concerns/MocksApplicationServices.php b/src/foundation/src/Testing/Concerns/MocksApplicationServices.php index ca7914008..0ef9b5bc0 100644 --- a/src/foundation/src/Testing/Concerns/MocksApplicationServices.php +++ b/src/foundation/src/Testing/Concerns/MocksApplicationServices.php @@ -5,7 +5,7 @@ namespace Hypervel\Foundation\Testing\Concerns; use Exception; -use Hyperf\Database\Model\Register; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Facades\Event; use Mockery; use Psr\EventDispatcher\EventDispatcherInterface; @@ -85,7 +85,7 @@ protected function withoutEvents() Event::clearResolvedInstances(); $this->app->set(EventDispatcherInterface::class, $mock); - Register::setEventDispatcher($mock); + Model::setEventDispatcher($mock); return $this; } diff --git a/src/foundation/src/Testing/Constraints/CountInDatabase.php b/src/foundation/src/Testing/Constraints/CountInDatabase.php index c283cf3e8..b29584a51 100644 --- a/src/foundation/src/Testing/Constraints/CountInDatabase.php +++ b/src/foundation/src/Testing/Constraints/CountInDatabase.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Testing\Constraints; -use Hyperf\DbConnection\Connection; +use Hypervel\Database\Connection; use PHPUnit\Framework\Constraint\Constraint; use ReflectionClass; diff --git a/src/foundation/src/Testing/Constraints/HasInDatabase.php b/src/foundation/src/Testing/Constraints/HasInDatabase.php index ed8a42445..06beb0272 100644 --- a/src/foundation/src/Testing/Constraints/HasInDatabase.php +++ b/src/foundation/src/Testing/Constraints/HasInDatabase.php @@ -4,8 +4,8 @@ namespace Hypervel\Foundation\Testing\Constraints; -use Hyperf\Database\Query\Expression; -use Hyperf\DbConnection\Connection; +use Hypervel\Database\Connection; +use Hypervel\Database\Query\Expression; use PHPUnit\Framework\Constraint\Constraint; class HasInDatabase extends Constraint diff --git a/src/foundation/src/Testing/Constraints/NotSoftDeletedInDatabase.php b/src/foundation/src/Testing/Constraints/NotSoftDeletedInDatabase.php index 57e7e4931..a09f94dee 100644 --- a/src/foundation/src/Testing/Constraints/NotSoftDeletedInDatabase.php +++ b/src/foundation/src/Testing/Constraints/NotSoftDeletedInDatabase.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Testing\Constraints; -use Hyperf\DbConnection\Connection; +use Hypervel\Database\Connection; use PHPUnit\Framework\Constraint\Constraint; class NotSoftDeletedInDatabase extends Constraint diff --git a/src/foundation/src/Testing/Constraints/SoftDeletedInDatabase.php b/src/foundation/src/Testing/Constraints/SoftDeletedInDatabase.php index 69b1bd0b3..51f52f3ff 100644 --- a/src/foundation/src/Testing/Constraints/SoftDeletedInDatabase.php +++ b/src/foundation/src/Testing/Constraints/SoftDeletedInDatabase.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Testing\Constraints; -use Hyperf\DbConnection\Connection; +use Hypervel\Database\Connection; use PHPUnit\Framework\Constraint\Constraint; class SoftDeletedInDatabase extends Constraint diff --git a/src/foundation/src/Testing/Contracts/Attributes/Actionable.php b/src/foundation/src/Testing/Contracts/Attributes/Actionable.php new file mode 100644 index 000000000..f6cfccc2e --- /dev/null +++ b/src/foundation/src/Testing/Contracts/Attributes/Actionable.php @@ -0,0 +1,21 @@ +):void $action + */ + public function handle(ApplicationContract $app, Closure $action): mixed; +} diff --git a/src/foundation/src/Testing/Contracts/Attributes/AfterAll.php b/src/foundation/src/Testing/Contracts/Attributes/AfterAll.php new file mode 100644 index 000000000..f82834655 --- /dev/null +++ b/src/foundation/src/Testing/Contracts/Attributes/AfterAll.php @@ -0,0 +1,16 @@ +getDefaultConnection(); diff --git a/src/foundation/src/Testing/DatabaseMigrations.php b/src/foundation/src/Testing/DatabaseMigrations.php index 45f73fc44..65678df75 100644 --- a/src/foundation/src/Testing/DatabaseMigrations.php +++ b/src/foundation/src/Testing/DatabaseMigrations.php @@ -15,12 +15,32 @@ trait DatabaseMigrations */ public function runDatabaseMigrations(): void { + $this->beforeRefreshingDatabase(); + $this->command('migrate:fresh', $this->migrateFreshUsing()); + $this->afterRefreshingDatabase(); + $this->beforeApplicationDestroyed(function () { $this->command('migrate:rollback'); RefreshDatabaseState::$migrated = false; }); } + + /** + * Perform any work that should take place before the database has started refreshing. + */ + protected function beforeRefreshingDatabase(): void + { + // ... + } + + /** + * Perform any work that should take place once the database has finished refreshing. + */ + protected function afterRefreshingDatabase(): void + { + // ... + } } diff --git a/src/foundation/src/Testing/DatabaseTransactions.php b/src/foundation/src/Testing/DatabaseTransactions.php index 2ceac3ef0..956f4b22b 100644 --- a/src/foundation/src/Testing/DatabaseTransactions.php +++ b/src/foundation/src/Testing/DatabaseTransactions.php @@ -4,8 +4,8 @@ namespace Hypervel\Foundation\Testing; -use Hyperf\Database\Connection as DatabaseConnection; -use Hyperf\DbConnection\Db; +use Hypervel\Database\Connection as DatabaseConnection; +use Hypervel\Database\DatabaseManager; trait DatabaseTransactions { @@ -14,7 +14,7 @@ trait DatabaseTransactions */ public function beginDatabaseTransaction(): void { - $database = $this->app->get(Db::class); + $database = $this->app->get(DatabaseManager::class); foreach ($this->connectionsToTransact() as $name) { $connection = $database->connection($name); diff --git a/src/foundation/src/Testing/Features/FeaturesCollection.php b/src/foundation/src/Testing/Features/FeaturesCollection.php new file mode 100644 index 000000000..175620be0 --- /dev/null +++ b/src/foundation/src/Testing/Features/FeaturesCollection.php @@ -0,0 +1,26 @@ +isEmpty()) { + return; + } + + $this->each($callback ?? static fn ($attribute) => value($attribute)); + } +} diff --git a/src/foundation/src/Testing/Http/TestClient.php b/src/foundation/src/Testing/Http/TestClient.php index 8ad780ab3..5298c3f08 100644 --- a/src/foundation/src/Testing/Http/TestClient.php +++ b/src/foundation/src/Testing/Http/TestClient.php @@ -4,7 +4,6 @@ namespace Hypervel\Foundation\Testing\Http; -use Hyperf\Collection\Arr; use Hyperf\Context\Context; use Hyperf\Contract\ConfigInterface; use Hyperf\Dispatcher\HttpDispatcher; @@ -19,6 +18,7 @@ use Hyperf\Testing\HttpMessage\Upload\UploadedFile; use Hypervel\Foundation\Http\Kernel as HttpKernel; use Hypervel\Foundation\Testing\Coroutine\Waiter; +use Hypervel\Support\Arr; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ResponseInterface; @@ -26,8 +26,6 @@ use Psr\Http\Message\UploadedFileInterface; use Throwable; -use function Hyperf\Collection\data_get; - class TestClient extends HttpKernel { protected bool $enableEvents = false; diff --git a/src/foundation/src/Testing/Http/TestResponse.php b/src/foundation/src/Testing/Http/TestResponse.php index 3377c0480..d74a32858 100644 --- a/src/foundation/src/Testing/Http/TestResponse.php +++ b/src/foundation/src/Testing/Http/TestResponse.php @@ -6,14 +6,14 @@ use Carbon\Carbon; use Closure; -use Hyperf\Collection\Arr; use Hyperf\Context\ApplicationContext; use Hyperf\Contract\MessageBag; use Hyperf\Testing\Http\TestResponse as HyperfTestResponse; use Hyperf\ViewEngine\ViewErrorBag; +use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Cookie\Cookie; use Hypervel\Foundation\Testing\TestResponseAssert as PHPUnit; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Support\Arr; use Psr\Http\Message\ResponseInterface; use RuntimeException; diff --git a/src/foundation/src/Testing/PendingCommand.php b/src/foundation/src/Testing/PendingCommand.php index 82ad35aba..75481e68d 100644 --- a/src/foundation/src/Testing/PendingCommand.php +++ b/src/foundation/src/Testing/PendingCommand.php @@ -4,18 +4,18 @@ namespace Hypervel\Foundation\Testing; -use Hyperf\Collection\Collection; use Hyperf\Command\Event\FailToHandle; use Hyperf\Conditionable\Conditionable; -use Hyperf\Contract\Arrayable; -use Hyperf\Macroable\Macroable; -use Hyperf\Tappable\Tappable; -use Hypervel\Container\Contracts\Container as ContainerContract; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Prompts\Note as PromptsNote; use Hypervel\Prompts\Prompt as BasePrompt; use Hypervel\Prompts\Table as PromptsTable; use Hypervel\Support\Arr; +use Hypervel\Support\Collection; +use Hypervel\Support\Traits\Macroable; +use Hypervel\Support\Traits\Tappable; use Mockery; use Mockery\Exception\NoMatchingExpectationException; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index 415fbd94a..c4dd262b1 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -5,9 +5,9 @@ namespace Hypervel\Foundation\Testing; use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\Connection as DatabaseConnection; -use Hyperf\Database\Model\Booted; -use Hyperf\DbConnection\Db; +use Hypervel\Database\Connection as DatabaseConnection; +use Hypervel\Database\DatabaseManager; +use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Testing\Traits\CanConfigureMigrationCommands; use Psr\EventDispatcher\EventDispatcherInterface; @@ -38,7 +38,7 @@ public function refreshDatabase(): void */ protected function refreshModelBootedStates(): void { - Booted::$container = []; + Model::clearBootedModels(); } /** @@ -46,7 +46,7 @@ protected function refreshModelBootedStates(): void */ protected function restoreInMemoryDatabase(): void { - $database = $this->app->get(Db::class); + $database = $this->app->get(DatabaseManager::class); foreach ($this->connectionsToTransact() as $name) { if (isset(RefreshDatabaseState::$inMemoryConnections[$name])) { @@ -100,7 +100,7 @@ protected function refreshTestDatabase(): void */ public function beginDatabaseTransaction(): void { - $database = $this->app->get(Db::class); + $database = $this->app->get(DatabaseManager::class); foreach ($this->connectionsToTransact() as $name) { $connection = $database->connection($name); @@ -113,7 +113,10 @@ public function beginDatabaseTransaction(): void $connection->unsetEventDispatcher(); $connection->beginTransaction(); - $connection->setEventDispatcher($dispatcher); + + if ($dispatcher) { + $connection->setEventDispatcher($dispatcher); + } } $this->beforeApplicationDestroyed(function () use ($database) { @@ -128,11 +131,14 @@ public function beginDatabaseTransaction(): void } if ($connection instanceof DatabaseConnection) { - $connection->resetRecordsModified(); + $connection->forgetRecordModificationState(); } $connection->rollBack(); - $connection->setEventDispatcher($dispatcher); + + if ($dispatcher) { + $connection->setEventDispatcher($dispatcher); + } // this will trigger a database refresh warning // $connection->disconnect(); } @@ -144,7 +150,7 @@ public function beginDatabaseTransaction(): void */ protected function withoutModelEvents(callable $callback, ?string $connection = null): void { - $connection = $this->app->get(Db::class) + $connection = $this->app->get(DatabaseManager::class) ->connection($connection); $dispatcher = $connection->getEventDispatcher(); diff --git a/src/foundation/src/Testing/TestCase.php b/src/foundation/src/Testing/TestCase.php index 24f8af7df..e75b7012c 100644 --- a/src/foundation/src/Testing/TestCase.php +++ b/src/foundation/src/Testing/TestCase.php @@ -6,6 +6,7 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; +use Faker\Generator as FakerGenerator; use Hyperf\Coroutine\Coroutine; use Hypervel\Foundation\Testing\Concerns\InteractsWithAuthentication; use Hypervel\Foundation\Testing\Concerns\InteractsWithConsole; @@ -16,7 +17,6 @@ use Hypervel\Foundation\Testing\Concerns\MakesHttpRequests; use Hypervel\Foundation\Testing\Concerns\MocksApplicationServices; use Hypervel\Support\Facades\Facade; -use Mockery; use Throwable; use function Hyperf\Coroutine\run; @@ -61,6 +61,8 @@ protected function setUp(): void $this->refreshApplication(); } + $this->setUpFaker(); + $this->runInCoroutine( fn () => $this->setUpTraits() ); @@ -114,6 +116,19 @@ protected function setUpTraits() return $uses; } + /** + * Set up Faker for factory usage. + */ + protected function setUpFaker(): void + { + if (! $this->app->bound(FakerGenerator::class)) { + $this->app->bind( + FakerGenerator::class, + fn () => \Faker\Factory::create($this->app->make('config')->get('app.faker_locale', 'en_US')) + ); + } + } + protected function tearDown(): void { if ($this->app) { @@ -132,14 +147,6 @@ protected function tearDown(): void throw $this->callbackException; } - if (class_exists('Mockery')) { - if ($container = Mockery::getContainer()) { // @phpstan-ignore if.alwaysTrue (defensive check) - $this->addToAssertionCount($container->mockery_getExpectationCount()); - } - - Mockery::close(); - } - if (class_exists(Carbon::class)) { Carbon::setTestNow(); } @@ -189,9 +196,29 @@ protected function callBeforeApplicationDestroyedCallbacks() /** * Ensure callback is executed in coroutine. + * + * Exceptions are captured and re-thrown outside the coroutine context + * so they propagate correctly to PHPUnit (e.g., for markTestSkipped). */ protected function runInCoroutine(callable $callback): void { - Coroutine::inCoroutine() ? $callback() : run($callback); + if (Coroutine::inCoroutine()) { + $callback(); + return; + } + + $exception = null; + + run(function () use ($callback, &$exception) { + try { + $callback(); + } catch (Throwable $e) { + $exception = $e; + } + }); + + if ($exception !== null) { + throw $exception; + } } } diff --git a/src/foundation/src/Testing/TestResponseAssert.php b/src/foundation/src/Testing/TestResponseAssert.php index 866e30238..752a49116 100644 --- a/src/foundation/src/Testing/TestResponseAssert.php +++ b/src/foundation/src/Testing/TestResponseAssert.php @@ -4,10 +4,10 @@ namespace Hypervel\Foundation\Testing; -use Hyperf\Collection\Arr; use Hyperf\Testing\Assert; use Hyperf\Testing\AssertableJsonString; use Hypervel\Foundation\Testing\Http\TestResponse; +use Hypervel\Support\Arr; use PHPUnit\Framework\ExpectationFailedException; use ReflectionProperty; use Throwable; diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index da526a6d4..d9e853e24 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -4,35 +4,35 @@ use Carbon\Carbon; use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\Arrayable; use Hyperf\HttpMessage\Cookie\Cookie; -use Hyperf\Stringable\Stringable; use Hyperf\ViewEngine\Contract\FactoryInterface; use Hyperf\ViewEngine\Contract\ViewInterface; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; -use Hypervel\Auth\Contracts\Gate; -use Hypervel\Auth\Contracts\Guard; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastFactory; use Hypervel\Broadcasting\PendingBroadcast; use Hypervel\Bus\PendingClosureDispatch; use Hypervel\Bus\PendingDispatch; -use Hypervel\Container\Contracts\Container; -use Hypervel\Cookie\Contracts\Cookie as CookieContract; +use Hypervel\Contracts\Auth\Access\Gate; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; +use Hypervel\Contracts\Auth\Guard; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastFactory; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Cookie\Cookie as CookieContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Session\Session as SessionContract; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Responsable; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; +use Hypervel\Contracts\Validation\Factory as ValidatorFactoryContract; +use Hypervel\Contracts\Validation\Validator as ValidatorContract; use Hypervel\Foundation\Application; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; use Hypervel\HttpMessage\Exceptions\HttpException; use Hypervel\HttpMessage\Exceptions\HttpResponseException; use Hypervel\HttpMessage\Exceptions\NotFoundHttpException; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; -use Hypervel\Session\Contracts\Session as SessionContract; -use Hypervel\Support\Contracts\Responsable; use Hypervel\Support\HtmlString; use Hypervel\Support\Mix; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; -use Hypervel\Validation\Contracts\Factory as ValidatorFactoryContract; -use Hypervel\Validation\Contracts\Validator as ValidatorContract; +use Hypervel\Support\Stringable; use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; @@ -279,7 +279,7 @@ function method_field(string $method): string * If an array is passed as the key, we will assume you want to set an array of values. * * @param null|array|string $key - * @return ($key is null ? \Hypervel\Config\Contracts\Repository : ($key is string ? mixed : null)) + * @return ($key is null ? \Hypervel\Contracts\Config\Repository : ($key is string ? mixed : null)) */ function config(mixed $key = null, mixed $default = null): mixed { diff --git a/src/hashing/src/ArgonHasher.php b/src/hashing/src/ArgonHasher.php index 78dc033d1..09571cab4 100644 --- a/src/hashing/src/ArgonHasher.php +++ b/src/hashing/src/ArgonHasher.php @@ -4,7 +4,7 @@ namespace Hypervel\Hashing; -use Hypervel\Hashing\Contracts\Hasher as HasherContract; +use Hypervel\Contracts\Hashing\Hasher as HasherContract; use RuntimeException; class ArgonHasher extends AbstractHasher implements HasherContract diff --git a/src/hashing/src/BcryptHasher.php b/src/hashing/src/BcryptHasher.php index ef746d5f9..8afbccdd0 100644 --- a/src/hashing/src/BcryptHasher.php +++ b/src/hashing/src/BcryptHasher.php @@ -4,7 +4,7 @@ namespace Hypervel\Hashing; -use Hypervel\Hashing\Contracts\Hasher as HasherContract; +use Hypervel\Contracts\Hashing\Hasher as HasherContract; use RuntimeException; class BcryptHasher extends AbstractHasher implements HasherContract diff --git a/src/hashing/src/ConfigProvider.php b/src/hashing/src/ConfigProvider.php index c474ab15c..9c95f33a8 100644 --- a/src/hashing/src/ConfigProvider.php +++ b/src/hashing/src/ConfigProvider.php @@ -4,7 +4,7 @@ namespace Hypervel\Hashing; -use Hypervel\Hashing\Contracts\Hasher; +use Hypervel\Contracts\Hashing\Hasher; class ConfigProvider { diff --git a/src/hashing/src/HashManager.php b/src/hashing/src/HashManager.php index d3fe0ac3e..83d0d1e2d 100644 --- a/src/hashing/src/HashManager.php +++ b/src/hashing/src/HashManager.php @@ -4,11 +4,11 @@ namespace Hypervel\Hashing; -use Hypervel\Hashing\Contracts\Hasher; +use Hypervel\Contracts\Hashing\Hasher; use Hypervel\Support\Manager; /** - * @mixin \Hypervel\Hashing\Contracts\Hasher + * @mixin \Hypervel\Contracts\Hashing\Hasher */ class HashManager extends Manager implements Hasher { diff --git a/src/horizon/src/AutoScaler.php b/src/horizon/src/AutoScaler.php index d74af3c72..5cc1c2517 100644 --- a/src/horizon/src/AutoScaler.php +++ b/src/horizon/src/AutoScaler.php @@ -4,8 +4,8 @@ namespace Hypervel\Horizon; +use Hypervel\Contracts\Queue\Factory as QueueFactory; use Hypervel\Horizon\Contracts\MetricsRepository; -use Hypervel\Queue\Contracts\Factory as QueueFactory; use Hypervel\Support\Collection; class AutoScaler @@ -81,6 +81,7 @@ protected function timeToClearPerQueue(Supervisor $supervisor, Collection $pools */ protected function numberOfWorkersPerQueue(Supervisor $supervisor, Collection $queues): Collection { + /** @var float $timeToClearAll */ $timeToClearAll = $queues->sum('time'); $totalJobs = $queues->sum('size'); diff --git a/src/horizon/src/Console/TerminateCommand.php b/src/horizon/src/Console/TerminateCommand.php index d71241316..01527a813 100644 --- a/src/horizon/src/Console/TerminateCommand.php +++ b/src/horizon/src/Console/TerminateCommand.php @@ -4,13 +4,13 @@ namespace Hypervel\Horizon\Console; -use Hypervel\Cache\Contracts\Factory as CacheFactory; use Hypervel\Console\Command; +use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Horizon\Contracts\MasterSupervisorRepository; use Hypervel\Horizon\MasterSupervisor; use Hypervel\Support\Arr; use Hypervel\Support\Str; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Support\InteractsWithTime; class TerminateCommand extends Command { diff --git a/src/horizon/src/HorizonServiceProvider.php b/src/horizon/src/HorizonServiceProvider.php index 93b2f2ae2..f2ab9903a 100644 --- a/src/horizon/src/HorizonServiceProvider.php +++ b/src/horizon/src/HorizonServiceProvider.php @@ -5,7 +5,7 @@ namespace Hypervel\Horizon; use Hyperf\Redis\RedisFactory; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Horizon\Connectors\RedisConnector; use Hypervel\Queue\QueueManager; use Hypervel\Support\Facades\Route; diff --git a/src/horizon/src/Http/Controllers/BatchesController.php b/src/horizon/src/Http/Controllers/BatchesController.php index 90ce98a6b..988fe464b 100644 --- a/src/horizon/src/Http/Controllers/BatchesController.php +++ b/src/horizon/src/Http/Controllers/BatchesController.php @@ -4,8 +4,8 @@ namespace Hypervel\Horizon\Http\Controllers; -use Hyperf\Database\Exception\QueryException; -use Hypervel\Bus\Contracts\BatchRepository; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Database\QueryException; use Hypervel\Horizon\Contracts\JobRepository; use Hypervel\Horizon\Jobs\RetryFailedJob; use Hypervel\Http\Request; diff --git a/src/horizon/src/Jobs/RetryFailedJob.php b/src/horizon/src/Jobs/RetryFailedJob.php index 8eaa2a1ab..6849b71f9 100644 --- a/src/horizon/src/Jobs/RetryFailedJob.php +++ b/src/horizon/src/Jobs/RetryFailedJob.php @@ -5,8 +5,8 @@ namespace Hypervel\Horizon\Jobs; use Carbon\CarbonImmutable; +use Hypervel\Contracts\Queue\Factory as Queue; use Hypervel\Horizon\Contracts\JobRepository; -use Hypervel\Queue\Contracts\Factory as Queue; use Hypervel\Support\Str; class RetryFailedJob diff --git a/src/horizon/src/Listeners/MarshalFailedEvent.php b/src/horizon/src/Listeners/MarshalFailedEvent.php index c0c8c43e4..fde0b3bbc 100644 --- a/src/horizon/src/Listeners/MarshalFailedEvent.php +++ b/src/horizon/src/Listeners/MarshalFailedEvent.php @@ -4,7 +4,7 @@ namespace Hypervel\Horizon\Listeners; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Horizon\Events\JobFailed; use Hypervel\Queue\Events\JobFailed as LaravelJobFailed; use Hypervel\Queue\Jobs\RedisJob; diff --git a/src/horizon/src/Listeners/MonitorWaitTimes.php b/src/horizon/src/Listeners/MonitorWaitTimes.php index b14ba5225..426368e98 100644 --- a/src/horizon/src/Listeners/MonitorWaitTimes.php +++ b/src/horizon/src/Listeners/MonitorWaitTimes.php @@ -51,7 +51,7 @@ public function handle(): void $long->each(function ($wait, $queue) { [$connection, $queue] = explode(':', $queue, 2); - event(new LongWaitDetected($connection, $queue, $wait)); + event(new LongWaitDetected($connection, $queue, (int) $wait)); }); } diff --git a/src/horizon/src/MasterSupervisor.php b/src/horizon/src/MasterSupervisor.php index 9d49249b7..8d2d68178 100644 --- a/src/horizon/src/MasterSupervisor.php +++ b/src/horizon/src/MasterSupervisor.php @@ -7,8 +7,8 @@ use Carbon\CarbonImmutable; use Closure; use Exception; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Horizon\Contracts\HorizonCommandQueue; use Hypervel\Horizon\Contracts\MasterSupervisorRepository; use Hypervel\Horizon\Contracts\Pausable; diff --git a/src/horizon/src/ProcessInspector.php b/src/horizon/src/ProcessInspector.php index b6205b164..a03965aea 100644 --- a/src/horizon/src/ProcessInspector.php +++ b/src/horizon/src/ProcessInspector.php @@ -49,7 +49,6 @@ public function monitoring(): array ->pluck('pid') ->pipe(function (Collection $processes) { foreach ($processes as $process) { - /** @var string $process */ $processes = $processes->merge($this->exec->run('pgrep -P ' . (string) $process)); } diff --git a/src/horizon/src/RedisQueue.php b/src/horizon/src/RedisQueue.php index 509cefa76..ea48d69e1 100644 --- a/src/horizon/src/RedisQueue.php +++ b/src/horizon/src/RedisQueue.php @@ -7,13 +7,13 @@ use DateInterval; use DateTimeInterface; use Hypervel\Context\Context; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; +use Hypervel\Contracts\Queue\Job; use Hypervel\Horizon\Events\JobDeleted; use Hypervel\Horizon\Events\JobPushed; use Hypervel\Horizon\Events\JobReleased; use Hypervel\Horizon\Events\JobReserved; use Hypervel\Horizon\Events\JobsMigrated; -use Hypervel\Queue\Jobs\Job; use Hypervel\Queue\Jobs\RedisJob; use Hypervel\Queue\RedisQueue as BaseQueue; use Hypervel\Support\Str; @@ -106,6 +106,7 @@ function ($payload, $queue, $delay) { public function pop(?string $queue = null, int $index = 0): ?Job { return tap(parent::pop($queue, $index), function ($result) use ($queue) { + /** @var null|RedisJob $result */ if ($result) { $this->event($this->getQueue($queue), new JobReserved($result->getReservedJob())); } @@ -119,7 +120,7 @@ public function pop(?string $queue = null, int $index = 0): ?Job public function migrateExpiredJobs(string $from, string $to): array { return tap(parent::migrateExpiredJobs($from, $to), function ($jobs) use ($to) { - $this->event($to, new JobsMigrated($jobs === false ? [] : $jobs)); + $this->event($to, new JobsMigrated($jobs)); }); } diff --git a/src/horizon/src/Repositories/RedisWorkloadRepository.php b/src/horizon/src/Repositories/RedisWorkloadRepository.php index a7d28eb08..7bf7f6e96 100644 --- a/src/horizon/src/Repositories/RedisWorkloadRepository.php +++ b/src/horizon/src/Repositories/RedisWorkloadRepository.php @@ -4,10 +4,10 @@ namespace Hypervel\Horizon\Repositories; +use Hypervel\Contracts\Queue\Factory as QueueFactory; use Hypervel\Horizon\Contracts\SupervisorRepository; use Hypervel\Horizon\Contracts\WorkloadRepository; use Hypervel\Horizon\WaitTimeCalculator; -use Hypervel\Queue\Contracts\Factory as QueueFactory; use Hypervel\Support\Str; class RedisWorkloadRepository implements WorkloadRepository diff --git a/src/horizon/src/Supervisor.php b/src/horizon/src/Supervisor.php index 8540c7faa..20c0d62ed 100644 --- a/src/horizon/src/Supervisor.php +++ b/src/horizon/src/Supervisor.php @@ -7,8 +7,8 @@ use Carbon\CarbonImmutable; use Closure; use Exception; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Horizon\Contracts\HorizonCommandQueue; use Hypervel\Horizon\Contracts\Pausable; use Hypervel\Horizon\Contracts\Restartable; diff --git a/src/horizon/src/WaitTimeCalculator.php b/src/horizon/src/WaitTimeCalculator.php index 84b8d4a6e..f5f9890c7 100644 --- a/src/horizon/src/WaitTimeCalculator.php +++ b/src/horizon/src/WaitTimeCalculator.php @@ -4,9 +4,9 @@ namespace Hypervel\Horizon; +use Hypervel\Contracts\Queue\Factory as QueueFactory; use Hypervel\Horizon\Contracts\MetricsRepository; use Hypervel\Horizon\Contracts\SupervisorRepository; -use Hypervel\Queue\Contracts\Factory as QueueFactory; use Hypervel\Support\Collection; use Hypervel\Support\Str; diff --git a/src/http-client/composer.json b/src/http-client/composer.json index 19ce00939..149b94112 100644 --- a/src/http-client/composer.json +++ b/src/http-client/composer.json @@ -27,7 +27,7 @@ }, "require": { "php": "^8.2", - "hyperf/macroable": "~3.1.0", + "hypervel/macroable": "~0.3.0", "guzzlehttp/guzzle": "^7.8.2", "guzzlehttp/uri-template": "^1.0", "hypervel/support": "^0.3" diff --git a/src/http-client/src/Factory.php b/src/http-client/src/Factory.php index f6e9186e8..0b8861d54 100644 --- a/src/http-client/src/Factory.php +++ b/src/http-client/src/Factory.php @@ -14,17 +14,15 @@ use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Response as Psr7Response; use GuzzleHttp\TransferStats; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Str; use Hypervel\ObjectPool\Traits\HasPoolProxy; use Hypervel\Support\Collection; +use Hypervel\Support\Str; +use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; use PHPUnit\Framework\Assert as PHPUnit; use Psr\EventDispatcher\EventDispatcherInterface; use Throwable; -use function Hyperf\Tappable\tap; - /** * @mixin \Hypervel\HttpClient\PendingRequest */ diff --git a/src/http-client/src/PendingRequest.php b/src/http-client/src/PendingRequest.php index 1c06f3c97..0a8b98def 100644 --- a/src/http-client/src/PendingRequest.php +++ b/src/http-client/src/PendingRequest.php @@ -17,15 +17,15 @@ use GuzzleHttp\TransferStats; use GuzzleHttp\UriTemplate\UriTemplate; use Hyperf\Conditionable\Conditionable; -use Hyperf\Contract\Arrayable; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Str; -use Hyperf\Stringable\Stringable; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\HttpClient\Events\ConnectionFailed; use Hypervel\HttpClient\Events\RequestSending; use Hypervel\HttpClient\Events\ResponseReceived; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Support\Str; +use Hypervel\Support\Stringable; +use Hypervel\Support\Traits\Macroable; use JsonSerializable; use OutOfBoundsException; use Psr\Http\Message\RequestInterface; @@ -625,7 +625,7 @@ public function dd(): static * * @throws ConnectionException */ - public function get(string $url, array|JsonSerializable|string|null $query = null): PromiseInterface|Response + public function get(string $url, Arrayable|array|JsonSerializable|string|null $query = null): PromiseInterface|Response { return $this->send( 'GET', @@ -1008,6 +1008,8 @@ protected function normalizeRequestOptions(array $options): array $options[$key] = match (true) { is_array($value) => $this->normalizeRequestOptions($value), $value instanceof Stringable => $value->toString(), + $value instanceof JsonSerializable => $value, + $value instanceof Arrayable => $this->normalizeRequestOptions($value->toArray()), default => $value, }; } diff --git a/src/http-client/src/Request.php b/src/http-client/src/Request.php index 8bc3f0de8..7724831d8 100644 --- a/src/http-client/src/Request.php +++ b/src/http-client/src/Request.php @@ -5,9 +5,9 @@ namespace Hypervel\HttpClient; use ArrayAccess; -use Hyperf\Collection\Arr; -use Hyperf\Macroable\Macroable; +use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Support\Traits\Macroable; use LogicException; use Psr\Http\Message\RequestInterface; diff --git a/src/http-client/src/Response.php b/src/http-client/src/Response.php index 95aa125d3..0716edaaf 100644 --- a/src/http-client/src/Response.php +++ b/src/http-client/src/Response.php @@ -9,10 +9,10 @@ use GuzzleHttp\Cookie\CookieJar; use GuzzleHttp\Psr7\StreamWrapper; use GuzzleHttp\TransferStats; -use Hyperf\Macroable\Macroable; use Hypervel\HttpClient\Concerns\DeterminesStatusCode; use Hypervel\Support\Collection; use Hypervel\Support\Fluent; +use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; use LogicException; use Psr\Http\Message\ResponseInterface; diff --git a/src/http-client/src/ResponseSequence.php b/src/http-client/src/ResponseSequence.php index e9652d58a..f40855493 100644 --- a/src/http-client/src/ResponseSequence.php +++ b/src/http-client/src/ResponseSequence.php @@ -6,7 +6,7 @@ use Closure; use GuzzleHttp\Promise\PromiseInterface; -use Hyperf\Macroable\Macroable; +use Hypervel\Support\Traits\Macroable; use OutOfBoundsException; class ResponseSequence diff --git a/src/http/composer.json b/src/http/composer.json index 31cb7e601..f45b6d0c6 100644 --- a/src/http/composer.json +++ b/src/http/composer.json @@ -28,9 +28,8 @@ "require": { "php": "^8.2", "hyperf/http-server": "~3.1.0", - "hyperf/collection": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/codec": "~3.1.0", + "hypervel/collections": "~0.3.0", + "hypervel/support": "~0.3.0", "hyperf/context": "~3.1.0", "hyperf/contract": "~3.1.0", "hyperf/resource": "~3.1.0", diff --git a/src/http/src/ConfigProvider.php b/src/http/src/ConfigProvider.php index 33fee1994..57cfd1607 100644 --- a/src/http/src/ConfigProvider.php +++ b/src/http/src/ConfigProvider.php @@ -5,7 +5,7 @@ namespace Hypervel\Http; use Hyperf\HttpServer\CoreMiddleware as HyperfCoreMiddleware; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Psr\Http\Message\ServerRequestInterface; class ConfigProvider diff --git a/src/http/src/CoreMiddleware.php b/src/http/src/CoreMiddleware.php index 2b9b642f6..ea0065e9c 100644 --- a/src/http/src/CoreMiddleware.php +++ b/src/http/src/CoreMiddleware.php @@ -5,11 +5,8 @@ namespace Hypervel\Http; use FastRoute\Dispatcher; -use Hyperf\Codec\Json; use Hyperf\Context\RequestContext; -use Hyperf\Contract\Arrayable; use Hyperf\Contract\ConfigInterface; -use Hyperf\Contract\Jsonable; use Hyperf\HttpMessage\Server\ResponsePlusProxy; use Hyperf\HttpMessage\Stream\SwooleStream; use Hyperf\HttpServer\Contract\CoreMiddlewareInterface; @@ -20,9 +17,12 @@ use Hyperf\ViewEngine\Contract\Renderable; use Hyperf\ViewEngine\Contract\ViewInterface; use Hypervel\Context\ResponseContext; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; use Hypervel\HttpMessage\Exceptions\MethodNotAllowedHttpException; use Hypervel\HttpMessage\Exceptions\NotFoundHttpException; use Hypervel\HttpMessage\Exceptions\ServerErrorHttpException; +use Hypervel\Support\Json; use Hypervel\View\Events\ViewRendered; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -73,7 +73,11 @@ protected function transferToResponse($response, ServerRequestInterface $request return new ResponsePlusProxy($response); } - if (is_array($response) || $response instanceof Arrayable) { + if ($response instanceof Arrayable) { + $response = $response->toArray(); + } + + if (is_array($response)) { return $this->response() ->addHeader('content-type', 'application/json') ->setBody(new SwooleStream(Json::encode($response))); @@ -82,7 +86,7 @@ protected function transferToResponse($response, ServerRequestInterface $request if ($response instanceof Jsonable) { return $this->response() ->addHeader('content-type', 'application/json') - ->setBody(new SwooleStream((string) $response)); + ->setBody(new SwooleStream($response->toJson())); } if ($this->response()->hasHeader('content-type')) { diff --git a/src/http/src/Cors.php b/src/http/src/Cors.php index 0fc7b6ba4..fa9ddf283 100644 --- a/src/http/src/Cors.php +++ b/src/http/src/Cors.php @@ -12,8 +12,8 @@ namespace Hypervel\Http; use Hypervel\Context\ApplicationContext; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Psr\Http\Message\ResponseInterface; /** diff --git a/src/http/src/Middleware/HandleCors.php b/src/http/src/Middleware/HandleCors.php index bc4e49047..738e15a95 100644 --- a/src/http/src/Middleware/HandleCors.php +++ b/src/http/src/Middleware/HandleCors.php @@ -5,8 +5,8 @@ namespace Hypervel\Http\Middleware; use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Http\Request as RequestContract; use Hypervel\Http\Cors; use Hypervel\Support\Str; use Psr\Container\ContainerInterface; diff --git a/src/http/src/Request.php b/src/http/src/Request.php index 3da99ee8d..4035d3bbe 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -7,27 +7,25 @@ use Carbon\Carbon; use Carbon\Exceptions\InvalidFormatException; use Closure; -use Hyperf\Collection\Arr; use Hyperf\Context\ApplicationContext; use Hyperf\Context\Context; use Hyperf\HttpServer\Request as HyperfRequest; use Hyperf\HttpServer\Router\Dispatched; -use Hyperf\Stringable\Str; use Hypervel\Context\RequestContext; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Session\Session as SessionContract; +use Hypervel\Contracts\Validation\Factory as ValidatorFactoryContract; +use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Hypervel\Support\Uri; -use Hypervel\Validation\Contracts\Factory as ValidatorFactoryContract; use Psr\Http\Message\ServerRequestInterface; use RuntimeException; use stdClass; use Stringable; use TypeError; -use function Hyperf\Collection\data_get; - class Request extends HyperfRequest implements RequestContract { /** diff --git a/src/http/src/Resources/Concerns/CollectsResources.php b/src/http/src/Resources/Concerns/CollectsResources.php index de0a35627..406760be9 100644 --- a/src/http/src/Resources/Concerns/CollectsResources.php +++ b/src/http/src/Resources/Concerns/CollectsResources.php @@ -4,6 +4,7 @@ namespace Hypervel\Http\Resources\Concerns; +use Hyperf\Collection\Collection as HyperfCollection; use Hyperf\Resource\Value\MissingValue; use Hypervel\Support\Collection; @@ -24,10 +25,15 @@ protected function collectResource(mixed $resource): mixed $collects = $this->collects(); - $this->collection = $collects && ! $resource->first() instanceof $collects + $mapped = $collects && ! $resource->first() instanceof $collects ? $resource->mapInto($collects) : $resource->toBase(); + // TODO: Remove once ResourceCollection is fully ported from Laravel. + // Temporary bridge during Hyperf decoupling - parent class property + // is typed as Hyperf\Collection\Collection, but we use Hypervel's. + $this->collection = new HyperfCollection($mapped->all()); + return $this->isPaginatorResource($resource) ? $resource->setCollection($this->collection) : $this->collection; diff --git a/src/http/src/Resources/Json/JsonResource.php b/src/http/src/Resources/Json/JsonResource.php index 2ad5e5ed6..f8446dcfc 100644 --- a/src/http/src/Resources/Json/JsonResource.php +++ b/src/http/src/Resources/Json/JsonResource.php @@ -5,9 +5,7 @@ namespace Hypervel\Http\Resources\Json; use Hyperf\Resource\Json\JsonResource as BaseJsonResource; -use Hypervel\Router\Contracts\UrlRoutable; - -use function Hyperf\Tappable\tap; +use Hypervel\Contracts\Router\UrlRoutable; class JsonResource extends BaseJsonResource implements UrlRoutable { diff --git a/src/http/src/Response.php b/src/http/src/Response.php index ad31bfe2f..24a55b6ee 100644 --- a/src/http/src/Response.php +++ b/src/http/src/Response.php @@ -5,20 +5,20 @@ namespace Hypervel\Http; use DateTimeImmutable; -use Hyperf\Codec\Json; use Hyperf\Context\ApplicationContext; use Hyperf\Context\Context; use Hyperf\Context\RequestContext; -use Hyperf\Contract\Arrayable; -use Hyperf\Contract\Jsonable; use Hyperf\HttpMessage\Server\Chunk\Chunkable; use Hyperf\HttpMessage\Stream\SwooleStream; use Hyperf\HttpServer\Response as HyperfResponse; use Hyperf\Support\Filesystem\Filesystem; use Hyperf\View\RenderInterface; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Response as ResponseContract; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; use Hypervel\Http\Exceptions\FileNotFoundException; use Hypervel\Support\Collection; +use Hypervel\Support\Json; use Hypervel\Support\MimeTypeExtensionGuesser; use Psr\Http\Message\ResponseInterface; use RuntimeException; @@ -165,14 +165,18 @@ public function make(mixed $content = '', int $status = 200, array $headers = [] foreach ($headers as $name => $value) { $response->addHeader($name, $value); } - if (is_array($content) || $content instanceof Arrayable) { + if ($content instanceof Arrayable) { + $content = $content->toArray(); + } + + if (is_array($content)) { return $response->addHeader('Content-Type', 'application/json') ->setBody(new SwooleStream(Json::encode($content))); } if ($content instanceof Jsonable) { return $response->addHeader('Content-Type', 'application/json') - ->setBody(new SwooleStream((string) $content)); + ->setBody(new SwooleStream($content->toJson())); } if ($response->hasHeader('Content-Type')) { diff --git a/src/http/src/UploadedFile.php b/src/http/src/UploadedFile.php index bc855f651..c0bed2841 100644 --- a/src/http/src/UploadedFile.php +++ b/src/http/src/UploadedFile.php @@ -4,12 +4,9 @@ namespace Hypervel\Http; -use Hyperf\Collection\Arr; use Hyperf\Context\ApplicationContext; use Hyperf\HttpMessage\Stream\StandardStream; use Hyperf\HttpMessage\Upload\UploadedFile as HyperfUploadedFile; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Str; use Hypervel\Filesystem\FilesystemManager; use Hypervel\Http\Exceptions\CannotWriteFileException; use Hypervel\Http\Exceptions\ExtensionFileException; @@ -21,8 +18,11 @@ use Hypervel\Http\Exceptions\NoTmpDirFileException; use Hypervel\Http\Exceptions\PartialFileException; use Hypervel\Http\Testing\FileFactory; +use Hypervel\Support\Arr; use Hypervel\Support\FileinfoMimeTypeGuesser; use Hypervel\Support\MimeTypeExtensionGuesser; +use Hypervel\Support\Str; +use Hypervel\Support\Traits\Macroable; use Psr\Http\Message\StreamInterface; class UploadedFile extends HyperfUploadedFile diff --git a/src/jwt/composer.json b/src/jwt/composer.json index b698b6951..215024c92 100644 --- a/src/jwt/composer.json +++ b/src/jwt/composer.json @@ -22,10 +22,9 @@ "require": { "php": "^8.2", "nesbot/carbon": "^2.72.6", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", "lcobucci/jwt": "^5.0", "psr/simple-cache": "^3.0", - "hyperf/stringable": "~3.1.0", "ramsey/uuid": "^4.7", "hypervel/cache": "^0.3" }, diff --git a/src/jwt/src/BlacklistFactory.php b/src/jwt/src/BlacklistFactory.php index 271d39307..ea7277ba6 100644 --- a/src/jwt/src/BlacklistFactory.php +++ b/src/jwt/src/BlacklistFactory.php @@ -5,7 +5,7 @@ namespace Hypervel\JWT; use Hyperf\Contract\ConfigInterface; -use Hypervel\Cache\Contracts\Factory as CacheManager; +use Hypervel\Contracts\Cache\Factory as CacheManager; use Hypervel\JWT\Contracts\BlacklistContract; use Hypervel\JWT\Storage\TaggedCache; use Psr\Container\ContainerInterface; diff --git a/src/jwt/src/JWTManager.php b/src/jwt/src/JWTManager.php index e86e2b2df..f2cbb1f14 100644 --- a/src/jwt/src/JWTManager.php +++ b/src/jwt/src/JWTManager.php @@ -4,15 +4,15 @@ namespace Hypervel\JWT; -use Hyperf\Collection\Collection; -use Hyperf\Stringable\Str; use Hypervel\JWT\Contracts\BlacklistContract; use Hypervel\JWT\Contracts\ManagerContract; use Hypervel\JWT\Contracts\ValidationContract; use Hypervel\JWT\Exceptions\JWTException; use Hypervel\JWT\Exceptions\TokenBlacklistedException; use Hypervel\JWT\Providers\Lcobucci; +use Hypervel\Support\Collection; use Hypervel\Support\Manager; +use Hypervel\Support\Str; use Psr\Container\ContainerInterface; use RuntimeException; diff --git a/src/jwt/src/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index f50f4cfec..14b75c9d7 100644 --- a/src/jwt/src/Providers/Lcobucci.php +++ b/src/jwt/src/Providers/Lcobucci.php @@ -7,10 +7,10 @@ use DateTimeImmutable; use DateTimeInterface; use Exception; -use Hyperf\Collection\Collection; use Hypervel\JWT\Contracts\ProviderContract; use Hypervel\JWT\Exceptions\JWTException; use Hypervel\JWT\Exceptions\TokenInvalidException; +use Hypervel\Support\Collection; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer; diff --git a/src/jwt/src/Providers/Provider.php b/src/jwt/src/Providers/Provider.php index 492115bfd..c3c269c9b 100644 --- a/src/jwt/src/Providers/Provider.php +++ b/src/jwt/src/Providers/Provider.php @@ -4,7 +4,7 @@ namespace Hypervel\JWT\Providers; -use Hyperf\Collection\Arr; +use Hypervel\Support\Arr; abstract class Provider { diff --git a/src/jwt/src/Storage/TaggedCache.php b/src/jwt/src/Storage/TaggedCache.php index ca756c336..24892fd5d 100644 --- a/src/jwt/src/Storage/TaggedCache.php +++ b/src/jwt/src/Storage/TaggedCache.php @@ -4,7 +4,7 @@ namespace Hypervel\JWT\Storage; -use Hypervel\Cache\Contracts\Repository as CacheContract; +use Hypervel\Contracts\Cache\Repository as CacheContract; use Hypervel\JWT\Contracts\StorageContract; class TaggedCache implements StorageContract diff --git a/src/log/composer.json b/src/log/composer.json index 317602dc3..17ddff9e7 100644 --- a/src/log/composer.json +++ b/src/log/composer.json @@ -29,9 +29,8 @@ "php": "^8.2", "hyperf/config": "~3.1.0", "monolog/monolog": "^3.1", - "hyperf/stringable": "~3.1.0", "hyperf/contract": "~3.1.0", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", "hypervel/support": "^0.3" }, "config": { diff --git a/src/log/src/LogManager.php b/src/log/src/LogManager.php index 80edd62c1..1beb02ebb 100644 --- a/src/log/src/LogManager.php +++ b/src/log/src/LogManager.php @@ -5,11 +5,11 @@ namespace Hypervel\Log; use Closure; -use Hyperf\Collection\Collection; use Hyperf\Context\Context; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; +use Hypervel\Support\Collection; use Hypervel\Support\Environment; +use Hypervel\Support\Str; use InvalidArgumentException; use Monolog\Formatter\LineFormatter; use Monolog\Handler\ErrorLogHandler; diff --git a/src/log/src/Logger.php b/src/log/src/Logger.php index f800edee0..e39461538 100755 --- a/src/log/src/Logger.php +++ b/src/log/src/Logger.php @@ -6,8 +6,8 @@ use Closure; use Hyperf\Context\Context; -use Hyperf\Contract\Arrayable; -use Hyperf\Contract\Jsonable; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; use Hypervel\Log\Events\MessageLogged; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -221,7 +221,7 @@ protected function formatMessage($message) return var_export($message, true); } if ($message instanceof Jsonable) { - return (string) $message; + return $message->toJson(); } if ($message instanceof Arrayable) { return var_export($message->toArray(), true); diff --git a/src/macroable/LICENSE.md b/src/macroable/LICENSE.md new file mode 100644 index 000000000..1fdd1ef99 --- /dev/null +++ b/src/macroable/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +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/macroable/composer.json b/src/macroable/composer.json new file mode 100644 index 000000000..74cb52423 --- /dev/null +++ b/src/macroable/composer.json @@ -0,0 +1,37 @@ +{ + "name": "hypervel/macroable", + "type": "library", + "description": "The Hypervel Macroable package.", + "license": "MIT", + "keywords": [ + "php", + "macroable", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Support\\": "src/" + } + }, + "require": { + "php": "^8.2" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.3-dev" + } + } +} diff --git a/src/support/src/Traits/Macroable.php b/src/macroable/src/Traits/Macroable.php similarity index 100% rename from src/support/src/Traits/Macroable.php rename to src/macroable/src/Traits/Macroable.php diff --git a/src/mail/composer.json b/src/mail/composer.json index a519d832d..afc43f2c0 100644 --- a/src/mail/composer.json +++ b/src/mail/composer.json @@ -27,10 +27,9 @@ }, "require": { "php": "^8.2", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", "hyperf/conditionable": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/macroable": "~3.1.0", + "hypervel/macroable": "~0.3.0", "hyperf/di": "~3.1.0", "hypervel/support": "^0.3", "hypervel/filesystem": "^0.3", diff --git a/src/mail/src/Attachment.php b/src/mail/src/Attachment.php index 6f5331c75..40525c221 100644 --- a/src/mail/src/Attachment.php +++ b/src/mail/src/Attachment.php @@ -6,10 +6,10 @@ use Closure; use Hyperf\Context\ApplicationContext; -use Hyperf\Macroable\Macroable; -use Hypervel\Filesystem\Contracts\Factory as FilesystemFactory; -use Hypervel\Filesystem\Contracts\Filesystem; +use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory; +use Hypervel\Contracts\Filesystem\Filesystem; use Hypervel\Notifications\Messages\MailMessage; +use Hypervel\Support\Traits\Macroable; use RuntimeException; use function Hyperf\Support\with; diff --git a/src/mail/src/Compiler/ComponentTagCompiler.php b/src/mail/src/Compiler/ComponentTagCompiler.php index 4af64932d..6645150bf 100644 --- a/src/mail/src/Compiler/ComponentTagCompiler.php +++ b/src/mail/src/Compiler/ComponentTagCompiler.php @@ -4,10 +4,10 @@ namespace Hypervel\Mail\Compiler; -use Hyperf\Stringable\Str; use Hyperf\ViewEngine\Blade; use Hyperf\ViewEngine\Compiler\ComponentTagCompiler as HyperfComponentTagCompiler; use Hyperf\ViewEngine\Contract\FactoryInterface; +use Hypervel\Support\Str; use InvalidArgumentException; class ComponentTagCompiler extends HyperfComponentTagCompiler diff --git a/src/mail/src/ConfigProvider.php b/src/mail/src/ConfigProvider.php index 163be2912..586c1af48 100644 --- a/src/mail/src/ConfigProvider.php +++ b/src/mail/src/ConfigProvider.php @@ -4,8 +4,8 @@ namespace Hypervel\Mail; -use Hypervel\Mail\Contracts\Factory as FactoryContract; -use Hypervel\Mail\Contracts\Mailer as MailerContract; +use Hypervel\Contracts\Mail\Factory as FactoryContract; +use Hypervel\Contracts\Mail\Mailer as MailerContract; class ConfigProvider { diff --git a/src/mail/src/Events/MessageSent.php b/src/mail/src/Events/MessageSent.php index 62b836ab2..587ee6e48 100644 --- a/src/mail/src/Events/MessageSent.php +++ b/src/mail/src/Events/MessageSent.php @@ -5,8 +5,8 @@ namespace Hypervel\Mail\Events; use Exception; -use Hyperf\Collection\Collection; use Hypervel\Mail\SentMessage; +use Hypervel\Support\Collection; class MessageSent { diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php index 564969b17..74028c6ab 100644 --- a/src/mail/src/MailManager.php +++ b/src/mail/src/MailManager.php @@ -7,20 +7,20 @@ use Aws\Ses\SesClient; use Aws\SesV2\SesV2Client; use Closure; -use Hyperf\Collection\Arr; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; use Hyperf\ViewEngine\Contract\FactoryInterface; +use Hypervel\Contracts\Mail\Factory as FactoryContract; +use Hypervel\Contracts\Mail\Mailer as MailerContract; +use Hypervel\Contracts\Queue\Factory as QueueFactory; use Hypervel\Log\LogManager; -use Hypervel\Mail\Contracts\Factory as FactoryContract; -use Hypervel\Mail\Contracts\Mailer as MailerContract; use Hypervel\Mail\Transport\ArrayTransport; use Hypervel\Mail\Transport\LogTransport; use Hypervel\Mail\Transport\SesTransport; use Hypervel\Mail\Transport\SesV2Transport; use Hypervel\ObjectPool\Traits\HasPoolProxy; -use Hypervel\Queue\Contracts\Factory as QueueFactory; +use Hypervel\Support\Arr; use Hypervel\Support\ConfigurationUrlParser; +use Hypervel\Support\Str; use InvalidArgumentException; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -39,7 +39,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; /** - * @mixin \Hypervel\Mail\Contracts\Mailer + * @mixin \Hypervel\Contracts\Mail\Mailer */ class MailManager implements FactoryContract { diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index 1c035a794..9d8def314 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -8,26 +8,26 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Collection\Collection; use Hyperf\Conditionable\Conditionable; use Hyperf\Context\ApplicationContext; use Hyperf\Contract\ConfigInterface; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Str; use Hyperf\Support\Traits\ForwardsCalls; -use Hypervel\Filesystem\Contracts\Factory as FilesystemFactory; +use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory; +use Hypervel\Contracts\Mail\Attachable; +use Hypervel\Contracts\Mail\Factory; +use Hypervel\Contracts\Mail\Factory as MailFactory; +use Hypervel\Contracts\Mail\Mailable as MailableContract; +use Hypervel\Contracts\Mail\Mailer; +use Hypervel\Contracts\Queue\Factory as QueueFactory; +use Hypervel\Contracts\Support\Htmlable; +use Hypervel\Contracts\Support\Renderable; +use Hypervel\Contracts\Translation\HasLocalePreference; use Hypervel\Foundation\Testing\Constraints\SeeInOrder; -use Hypervel\Mail\Contracts\Attachable; -use Hypervel\Mail\Contracts\Factory; -use Hypervel\Mail\Contracts\Factory as MailFactory; -use Hypervel\Mail\Contracts\Mailable as MailableContract; -use Hypervel\Mail\Contracts\Mailer; -use Hypervel\Queue\Contracts\Factory as QueueFactory; -use Hypervel\Support\Contracts\Htmlable; -use Hypervel\Support\Contracts\Renderable; +use Hypervel\Support\Collection; use Hypervel\Support\HtmlString; +use Hypervel\Support\Str; use Hypervel\Support\Traits\Localizable; -use Hypervel\Translation\Contracts\HasLocalePreference; +use Hypervel\Support\Traits\Macroable; use PHPUnit\Framework\Assert as PHPUnit; use ReflectionClass; use ReflectionException; diff --git a/src/mail/src/Mailables/Envelope.php b/src/mail/src/Mailables/Envelope.php index 8ce4bc5ac..fd9836dff 100644 --- a/src/mail/src/Mailables/Envelope.php +++ b/src/mail/src/Mailables/Envelope.php @@ -5,9 +5,9 @@ namespace Hypervel\Mail\Mailables; use Closure; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\Conditionable\Conditionable; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; class Envelope { diff --git a/src/mail/src/Mailables/Headers.php b/src/mail/src/Mailables/Headers.php index 08f0b5f5a..6f12fdb80 100644 --- a/src/mail/src/Mailables/Headers.php +++ b/src/mail/src/Mailables/Headers.php @@ -4,9 +4,9 @@ namespace Hypervel\Mail\Mailables; -use Hyperf\Collection\Collection; use Hyperf\Conditionable\Conditionable; -use Hyperf\Stringable\Str; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; class Headers { diff --git a/src/mail/src/Mailer.php b/src/mail/src/Mailer.php index 663c072be..4b0f0d7b1 100644 --- a/src/mail/src/Mailer.php +++ b/src/mail/src/Mailer.php @@ -7,19 +7,19 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Macroable\Macroable; use Hyperf\ViewEngine\Contract\FactoryInterface; -use Hypervel\Mail\Contracts\Mailable; -use Hypervel\Mail\Contracts\Mailable as MailableContract; -use Hypervel\Mail\Contracts\Mailer as MailerContract; -use Hypervel\Mail\Contracts\MailQueue as MailQueueContract; +use Hypervel\Contracts\Mail\Mailable; +use Hypervel\Contracts\Mail\Mailable as MailableContract; +use Hypervel\Contracts\Mail\Mailer as MailerContract; +use Hypervel\Contracts\Mail\MailQueue as MailQueueContract; +use Hypervel\Contracts\Queue\Factory as QueueFactory; +use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Contracts\Support\Htmlable; use Hypervel\Mail\Events\MessageSending; use Hypervel\Mail\Events\MessageSent; use Hypervel\Mail\Mailables\Address; -use Hypervel\Queue\Contracts\Factory as QueueFactory; -use Hypervel\Queue\Contracts\ShouldQueue; -use Hypervel\Support\Contracts\Htmlable; use Hypervel\Support\HtmlString; +use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Mailer\Envelope; @@ -28,7 +28,6 @@ use Symfony\Component\Mime\Email; use function Hyperf\Support\value; -use function Hyperf\Tappable\tap; class Mailer implements MailerContract, MailQueueContract { diff --git a/src/mail/src/MailerFactory.php b/src/mail/src/MailerFactory.php index 76d51dfda..b17e5a2c9 100644 --- a/src/mail/src/MailerFactory.php +++ b/src/mail/src/MailerFactory.php @@ -4,8 +4,8 @@ namespace Hypervel\Mail; -use Hypervel\Mail\Contracts\Factory; -use Hypervel\Mail\Contracts\Mailer as MailerContract; +use Hypervel\Contracts\Mail\Factory; +use Hypervel\Contracts\Mail\Mailer as MailerContract; class MailerFactory { diff --git a/src/mail/src/Markdown.php b/src/mail/src/Markdown.php index d0a59f6d3..a36ccb434 100644 --- a/src/mail/src/Markdown.php +++ b/src/mail/src/Markdown.php @@ -4,9 +4,9 @@ namespace Hypervel\Mail; -use Hyperf\Stringable\Str; use Hyperf\ViewEngine\Contract\FactoryInterface; use Hypervel\Support\HtmlString; +use Hypervel\Support\Str; use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\Extension\Table\TableExtension; diff --git a/src/mail/src/Message.php b/src/mail/src/Message.php index a3b922b22..e2396278a 100644 --- a/src/mail/src/Message.php +++ b/src/mail/src/Message.php @@ -4,9 +4,9 @@ namespace Hypervel\Mail; -use Hyperf\Stringable\Str; use Hyperf\Support\Traits\ForwardsCalls; -use Hypervel\Mail\Contracts\Attachable; +use Hypervel\Contracts\Mail\Attachable; +use Hypervel\Support\Str; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Part\DataPart; diff --git a/src/mail/src/PendingMail.php b/src/mail/src/PendingMail.php index 7781cf5ca..9a0259f14 100644 --- a/src/mail/src/PendingMail.php +++ b/src/mail/src/PendingMail.php @@ -7,10 +7,8 @@ use DateInterval; use DateTimeInterface; use Hyperf\Conditionable\Conditionable; -use Hypervel\Mail\Contracts\Mailable as MailableContract; -use Hypervel\Mail\Contracts\Mailer as MailerContract; - -use function Hyperf\Tappable\tap; +use Hypervel\Contracts\Mail\Mailable as MailableContract; +use Hypervel\Contracts\Mail\Mailer as MailerContract; class PendingMail { diff --git a/src/mail/src/SendQueuedMailable.php b/src/mail/src/SendQueuedMailable.php index 8082306d5..b8e01c265 100644 --- a/src/mail/src/SendQueuedMailable.php +++ b/src/mail/src/SendQueuedMailable.php @@ -6,10 +6,10 @@ use DateTime; use Hypervel\Bus\Queueable; -use Hypervel\Mail\Contracts\Factory as MailFactory; -use Hypervel\Mail\Contracts\Mailable as MailableContract; -use Hypervel\Queue\Contracts\ShouldBeEncrypted; -use Hypervel\Queue\Contracts\ShouldQueueAfterCommit; +use Hypervel\Contracts\Mail\Factory as MailFactory; +use Hypervel\Contracts\Mail\Mailable as MailableContract; +use Hypervel\Contracts\Queue\ShouldBeEncrypted; +use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Queue\InteractsWithQueue; use Throwable; diff --git a/src/mail/src/SentMessage.php b/src/mail/src/SentMessage.php index 44be62768..8869acc47 100644 --- a/src/mail/src/SentMessage.php +++ b/src/mail/src/SentMessage.php @@ -4,8 +4,8 @@ namespace Hypervel\Mail; -use Hyperf\Collection\Collection; use Hyperf\Support\Traits\ForwardsCalls; +use Hypervel\Support\Collection; use Symfony\Component\Mailer\SentMessage as SymfonySentMessage; /** diff --git a/src/mail/src/TextMessage.php b/src/mail/src/TextMessage.php index 748bf1e6b..01cfb198e 100644 --- a/src/mail/src/TextMessage.php +++ b/src/mail/src/TextMessage.php @@ -5,7 +5,7 @@ namespace Hypervel\Mail; use Hyperf\Support\Traits\ForwardsCalls; -use Hypervel\Mail\Contracts\Attachable; +use Hypervel\Contracts\Mail\Attachable; /** * @mixin Message diff --git a/src/mail/src/Transport/ArrayTransport.php b/src/mail/src/Transport/ArrayTransport.php index d19cb8a1b..9e7fc665f 100644 --- a/src/mail/src/Transport/ArrayTransport.php +++ b/src/mail/src/Transport/ArrayTransport.php @@ -4,7 +4,7 @@ namespace Hypervel\Mail\Transport; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; use Stringable; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\SentMessage; diff --git a/src/mail/src/Transport/LogTransport.php b/src/mail/src/Transport/LogTransport.php index 602f3ffa5..accde79a0 100644 --- a/src/mail/src/Transport/LogTransport.php +++ b/src/mail/src/Transport/LogTransport.php @@ -4,7 +4,7 @@ namespace Hypervel\Mail\Transport; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; use Psr\Log\LoggerInterface; use Stringable; use Symfony\Component\Mailer\Envelope; diff --git a/src/mail/src/Transport/SesTransport.php b/src/mail/src/Transport/SesTransport.php index f26fbd27e..196ab6d80 100644 --- a/src/mail/src/Transport/SesTransport.php +++ b/src/mail/src/Transport/SesTransport.php @@ -43,6 +43,7 @@ protected function doSend(SentMessage $message): void $options, [ 'Source' => $message->getEnvelope()->getSender()->toString(), + // @phpstan-ignore method.nonObject (Higher Order Message: ->map->toString() returns Collection, not string) 'Destinations' => collect($message->getEnvelope()->getRecipients()) ->map ->toString() diff --git a/src/mail/src/Transport/SesV2Transport.php b/src/mail/src/Transport/SesV2Transport.php index aeae41ad7..25c68f1f0 100644 --- a/src/mail/src/Transport/SesV2Transport.php +++ b/src/mail/src/Transport/SesV2Transport.php @@ -44,6 +44,7 @@ protected function doSend(SentMessage $message): void [ 'Source' => $message->getEnvelope()->getSender()->toString(), 'Destination' => [ + // @phpstan-ignore method.nonObject (Higher Order Message: ->map->toString() returns Collection, not string) 'ToAddresses' => collect($message->getEnvelope()->getRecipients()) ->map ->toString() diff --git a/src/nested-set/composer.json b/src/nested-set/composer.json index 597431bc4..6f8ae9f4f 100644 --- a/src/nested-set/composer.json +++ b/src/nested-set/composer.json @@ -27,8 +27,8 @@ }, "require": { "php": "^8.2", - "hyperf/database": "~3.1.0", "hypervel/core": "^0.3", + "hypervel/database": "^0.3", "hypervel/support": "^0.3" }, "config": { diff --git a/src/nested-set/src/Eloquent/AncestorsRelation.php b/src/nested-set/src/Eloquent/AncestorsRelation.php index d10a97cd5..f2b95508f 100644 --- a/src/nested-set/src/Eloquent/AncestorsRelation.php +++ b/src/nested-set/src/Eloquent/AncestorsRelation.php @@ -4,8 +4,7 @@ namespace Hypervel\NestedSet\Eloquent; -use Hyperf\Database\Model\Model; -use Hyperf\Database\Model\Relations\Constraint; +use Hypervel\Database\Eloquent\Model; class AncestorsRelation extends BaseRelation { @@ -14,7 +13,7 @@ class AncestorsRelation extends BaseRelation */ public function addConstraints(): void { - if (! Constraint::isConstraint()) { + if (! static::shouldAddConstraints()) { return; } diff --git a/src/nested-set/src/Eloquent/BaseRelation.php b/src/nested-set/src/Eloquent/BaseRelation.php index e285a1f64..b692dfb72 100644 --- a/src/nested-set/src/Eloquent/BaseRelation.php +++ b/src/nested-set/src/Eloquent/BaseRelation.php @@ -4,11 +4,11 @@ namespace Hypervel\NestedSet\Eloquent; -use Hyperf\Database\Model\Builder as EloquentBuilder; -use Hyperf\Database\Model\Collection; -use Hyperf\Database\Model\Model; -use Hyperf\Database\Model\Relations\Relation; -use Hyperf\Database\Query\Builder; +use Hypervel\Database\Eloquent\Builder as EloquentBuilder; +use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Database\Query\Builder; use Hypervel\NestedSet\NestedSet; use InvalidArgumentException; @@ -37,10 +37,7 @@ abstract protected function addEagerConstraint(QueryBuilder $query, Model $model abstract protected function relationExistenceCondition(string $hash, string $table, string $lft, string $rgt): string; - /** - * @param array $columns - */ - public function getRelationExistenceQuery(EloquentBuilder $query, EloquentBuilder $parent, $columns = ['*']): mixed + public function getRelationExistenceQuery(EloquentBuilder $query, EloquentBuilder $parentQuery, mixed $columns = ['*']): EloquentBuilder { /* @phpstan-ignore-next-line */ $query = $this->getParent()->replicate()->newScopedQuery()->select($columns); diff --git a/src/nested-set/src/Eloquent/DescendantsRelation.php b/src/nested-set/src/Eloquent/DescendantsRelation.php index c1f4aedb4..8780e225f 100644 --- a/src/nested-set/src/Eloquent/DescendantsRelation.php +++ b/src/nested-set/src/Eloquent/DescendantsRelation.php @@ -4,8 +4,7 @@ namespace Hypervel\NestedSet\Eloquent; -use Hyperf\Database\Model\Model; -use Hyperf\Database\Model\Relations\Constraint; +use Hypervel\Database\Eloquent\Model; class DescendantsRelation extends BaseRelation { @@ -14,7 +13,7 @@ class DescendantsRelation extends BaseRelation */ public function addConstraints(): void { - if (! Constraint::isConstraint()) { + if (! static::shouldAddConstraints()) { return; } diff --git a/src/nested-set/src/Eloquent/QueryBuilder.php b/src/nested-set/src/Eloquent/QueryBuilder.php index 970f934c3..87099bcff 100644 --- a/src/nested-set/src/Eloquent/QueryBuilder.php +++ b/src/nested-set/src/Eloquent/QueryBuilder.php @@ -4,14 +4,14 @@ namespace Hypervel\NestedSet\Eloquent; -use Hyperf\Collection\Collection as HyperfCollection; -use Hyperf\Database\Model\Builder as EloquentBuilder; -use Hyperf\Database\Model\Model; -use Hyperf\Database\Model\ModelNotFoundException; -use Hyperf\Database\Query\Builder as BaseQueryBuilder; -use Hyperf\Database\Query\Expression; +use Hypervel\Database\Eloquent\Builder as EloquentBuilder; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\ModelNotFoundException; +use Hypervel\Database\Query\Builder as BaseQueryBuilder; +use Hypervel\Database\Query\Expression; use Hypervel\NestedSet\NestedSet; use Hypervel\Support\Arr; +use Hypervel\Support\Collection as BaseCollection; use LogicException; class QueryBuilder extends EloquentBuilder @@ -115,13 +115,13 @@ public function whereAncestorOrSelf(mixed $id): static /** * Get ancestors of specified node. */ - public function ancestorsOf(mixed $id, array $columns = ['*']): HyperfCollection + public function ancestorsOf(mixed $id, array $columns = ['*']): BaseCollection { /* @phpstan-ignore-next-line */ return $this->whereAncestorOf($id)->get($columns); } - public function ancestorsAndSelf(mixed $id, array $columns = ['*']): HyperfCollection + public function ancestorsAndSelf(mixed $id, array $columns = ['*']): BaseCollection { /* @phpstan-ignore-next-line */ return $this->whereAncestorOf($id, true)->get($columns); @@ -197,7 +197,7 @@ public function whereDescendantOrSelf(mixed $id, string $boolean = 'and', bool $ /** * Get descendants of specified node. */ - public function descendantsOf(mixed $id, array $columns = ['*'], bool $andSelf = false): HyperfCollection + public function descendantsOf(mixed $id, array $columns = ['*'], bool $andSelf = false): BaseCollection { try { return $this->whereDescendantOf($id, 'and', false, $andSelf)->get($columns); @@ -206,7 +206,7 @@ public function descendantsOf(mixed $id, array $columns = ['*'], bool $andSelf = } } - public function descendantsAndSelf(mixed $id, array $columns = ['*']): HyperfCollection + public function descendantsAndSelf(mixed $id, array $columns = ['*']): BaseCollection { return $this->descendantsOf($id, $columns, true); } @@ -260,7 +260,7 @@ public function whereIsLeaf(): BaseQueryBuilder|QueryBuilder return $this->whereRaw("{$lft} = {$rgt} - 1"); } - public function leaves(array $columns = ['*']): HyperfCollection + public function leaves(array $columns = ['*']): BaseCollection { return $this->whereIsLeaf()->get($columns); } diff --git a/src/nested-set/src/HasNode.php b/src/nested-set/src/HasNode.php index 4677d5659..fec758967 100644 --- a/src/nested-set/src/HasNode.php +++ b/src/nested-set/src/HasNode.php @@ -6,10 +6,10 @@ use Carbon\Carbon; use Exception; -use Hyperf\Database\Model\Model; -use Hyperf\Database\Model\Relations\BelongsTo; -use Hyperf\Database\Model\Relations\HasMany; -use Hyperf\Database\Query\Builder as HyperfQueryBuilder; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsTo; +use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Query\Builder as HyperfQueryBuilder; use Hypervel\NestedSet\Eloquent\AncestorsRelation; use Hypervel\NestedSet\Eloquent\Collection; use Hypervel\NestedSet\Eloquent\DescendantsRelation; @@ -40,33 +40,28 @@ trait HasNode */ protected static ?bool $hasSoftDelete = null; + /** + * Create a new Eloquent query builder for the model. + */ + public function newEloquentBuilder(HyperfQueryBuilder $query): QueryBuilder + { + return new QueryBuilder($query); + } + /** * Bootstrap node events. */ public static function bootHasNode(): void { - static::registerCallback( - 'saving', - fn ($model) => $model->callPendingActions() - ); + static::saving(fn ($model) => $model->callPendingActions()); - static::registerCallback( - 'deleting', - fn ($model) => $model->refreshNode() - ); + static::deleting(fn ($model) => $model->refreshNode()); - static::registerCallback( - 'deleted', - fn ($model) => $model->deleteDescendants() - ); + static::deleted(fn ($model) => $model->deleteDescendants()); if (static::usesSoftDelete()) { - static::registerCallback( - 'restoring', - fn ($model) => NodeContext::keepDeletedAt($model) - ); - static::registerCallback( - 'restored', + static::restoring(fn ($model) => NodeContext::keepDeletedAt($model)); + static::restored( fn ($model) => $model->restoreDescendants(NodeContext::restoreDeletedAt($model)) ); } @@ -973,7 +968,7 @@ protected function isSameScope(self $node): bool return true; } - public function replicate(?array $except = null): Model + public function replicate(?array $except = null): static { $defaults = [ $this->getParentIdName(), diff --git a/src/nested-set/src/NestedSet.php b/src/nested-set/src/NestedSet.php index 9685199f2..aac17f82f 100644 --- a/src/nested-set/src/NestedSet.php +++ b/src/nested-set/src/NestedSet.php @@ -4,7 +4,7 @@ namespace Hypervel\NestedSet; -use Hyperf\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Blueprint; class NestedSet { diff --git a/src/nested-set/src/NodeContext.php b/src/nested-set/src/NodeContext.php index 1608dce58..88e412c83 100644 --- a/src/nested-set/src/NodeContext.php +++ b/src/nested-set/src/NodeContext.php @@ -5,8 +5,8 @@ namespace Hypervel\NestedSet; use DateTimeInterface; -use Hyperf\Database\Model\Model; use Hypervel\Context\Context; +use Hypervel\Database\Eloquent\Model; class NodeContext { diff --git a/src/notifications/composer.json b/src/notifications/composer.json index a7564ad46..10aed704f 100644 --- a/src/notifications/composer.json +++ b/src/notifications/composer.json @@ -28,12 +28,11 @@ "require": { "php": "^8.2", "hyperf/config": "~3.1.0", - "hyperf/stringable": "~3.1.0", "hyperf/contract": "~3.1.0", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", "hyperf/context": "~3.1.0", - "hyperf/database": "~3.1.0", "hyperf/di": "~3.1.0", + "hypervel/database": "^0.3", "hypervel/broadcasting": "^0.3", "hypervel/support": "^0.3", "hypervel/mail": "^0.3", diff --git a/src/notifications/src/AnonymousNotifiable.php b/src/notifications/src/AnonymousNotifiable.php index 150dd1458..464b81b01 100644 --- a/src/notifications/src/AnonymousNotifiable.php +++ b/src/notifications/src/AnonymousNotifiable.php @@ -5,7 +5,7 @@ namespace Hypervel\Notifications; use Hyperf\Context\ApplicationContext; -use Hypervel\Notifications\Contracts\Dispatcher; +use Hypervel\Contracts\Notifications\Dispatcher; use InvalidArgumentException; class AnonymousNotifiable diff --git a/src/notifications/src/ChannelManager.php b/src/notifications/src/ChannelManager.php index 88ff99c24..1e9a5b54a 100644 --- a/src/notifications/src/ChannelManager.php +++ b/src/notifications/src/ChannelManager.php @@ -6,16 +6,16 @@ use Closure; use Hyperf\Context\Context; -use Hyperf\Stringable\Str; -use Hypervel\Bus\Contracts\Dispatcher as BusDispatcherContract; +use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Hypervel\Contracts\Notifications\Dispatcher as DispatcherContract; +use Hypervel\Contracts\Notifications\Factory as FactoryContract; use Hypervel\Notifications\Channels\BroadcastChannel; use Hypervel\Notifications\Channels\DatabaseChannel; use Hypervel\Notifications\Channels\MailChannel; use Hypervel\Notifications\Channels\SlackNotificationRouterChannel; -use Hypervel\Notifications\Contracts\Dispatcher as DispatcherContract; -use Hypervel\Notifications\Contracts\Factory as FactoryContract; use Hypervel\ObjectPool\Traits\HasPoolProxy; use Hypervel\Support\Manager; +use Hypervel\Support\Str; use InvalidArgumentException; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/src/notifications/src/Channels/DatabaseChannel.php b/src/notifications/src/Channels/DatabaseChannel.php index 127b49240..066fbd966 100644 --- a/src/notifications/src/Channels/DatabaseChannel.php +++ b/src/notifications/src/Channels/DatabaseChannel.php @@ -4,7 +4,7 @@ namespace Hypervel\Notifications\Channels; -use Hyperf\Database\Model\Model; +use Hypervel\Database\Eloquent\Model; use Hypervel\Notifications\Notification; use RuntimeException; diff --git a/src/notifications/src/Channels/MailChannel.php b/src/notifications/src/Channels/MailChannel.php index 7f2915a6f..978af336c 100644 --- a/src/notifications/src/Channels/MailChannel.php +++ b/src/notifications/src/Channels/MailChannel.php @@ -5,18 +5,18 @@ namespace Hypervel\Notifications\Channels; use Closure; -use Hyperf\Collection\Arr; use Hyperf\Context\ApplicationContext; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; -use Hypervel\Mail\Contracts\Factory as MailFactory; -use Hypervel\Mail\Contracts\Mailable; +use Hypervel\Contracts\Mail\Factory as MailFactory; +use Hypervel\Contracts\Mail\Mailable; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Mail\Markdown; use Hypervel\Mail\Message; use Hypervel\Mail\SentMessage; use Hypervel\Notifications\Messages\MailMessage; use Hypervel\Notifications\Notification; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Support\Arr; +use Hypervel\Support\Str; use RuntimeException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mailer\Header\TagHeader; diff --git a/src/notifications/src/Channels/SlackNotificationRouterChannel.php b/src/notifications/src/Channels/SlackNotificationRouterChannel.php index f448928a2..728b50e37 100644 --- a/src/notifications/src/Channels/SlackNotificationRouterChannel.php +++ b/src/notifications/src/Channels/SlackNotificationRouterChannel.php @@ -4,8 +4,8 @@ namespace Hypervel\Notifications\Channels; -use Hyperf\Stringable\Str; use Hypervel\Notifications\Notification; +use Hypervel\Support\Str; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; diff --git a/src/notifications/src/Channels/SlackWebhookChannel.php b/src/notifications/src/Channels/SlackWebhookChannel.php index 21323f48e..94b2b081b 100644 --- a/src/notifications/src/Channels/SlackWebhookChannel.php +++ b/src/notifications/src/Channels/SlackWebhookChannel.php @@ -5,16 +5,14 @@ namespace Hypervel\Notifications\Channels; use GuzzleHttp\Client as HttpClient; -use Hyperf\Collection\Collection; use Hypervel\Notifications\Messages\SlackAttachment; use Hypervel\Notifications\Messages\SlackAttachmentField; use Hypervel\Notifications\Messages\SlackMessage; use Hypervel\Notifications\Notification; +use Hypervel\Support\Collection; use Psr\Http\Message\ResponseInterface; use RuntimeException; -use function Hyperf\Collection\data_get; - class SlackWebhookChannel { /** diff --git a/src/notifications/src/ConfigProvider.php b/src/notifications/src/ConfigProvider.php index d0e95b273..b32da781d 100644 --- a/src/notifications/src/ConfigProvider.php +++ b/src/notifications/src/ConfigProvider.php @@ -4,7 +4,7 @@ namespace Hypervel\Notifications; -use Hypervel\Notifications\Contracts\Dispatcher as NotificationDispatcher; +use Hypervel\Contracts\Notifications\Dispatcher as NotificationDispatcher; class ConfigProvider { diff --git a/src/notifications/src/Contracts/Slack/BlockContract.php b/src/notifications/src/Contracts/Slack/BlockContract.php deleted file mode 100644 index 2b306c51b..000000000 --- a/src/notifications/src/Contracts/Slack/BlockContract.php +++ /dev/null @@ -1,11 +0,0 @@ - + * @extends \Hypervel\Database\Eloquent\Collection */ class DatabaseNotificationCollection extends Collection { diff --git a/src/notifications/src/Events/BroadcastNotificationCreated.php b/src/notifications/src/Events/BroadcastNotificationCreated.php index e2bc76a0b..3c9f9f28a 100644 --- a/src/notifications/src/Events/BroadcastNotificationCreated.php +++ b/src/notifications/src/Events/BroadcastNotificationCreated.php @@ -4,14 +4,14 @@ namespace Hypervel\Notifications\Events; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; -use Hypervel\Broadcasting\Contracts\ShouldBroadcast; use Hypervel\Broadcasting\PrivateChannel; use Hypervel\Bus\Queueable; +use Hypervel\Contracts\Broadcasting\ShouldBroadcast; use Hypervel\Notifications\AnonymousNotifiable; use Hypervel\Notifications\Notification; use Hypervel\Queue\SerializesModels; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; class BroadcastNotificationCreated implements ShouldBroadcast { diff --git a/src/notifications/src/HasDatabaseNotifications.php b/src/notifications/src/HasDatabaseNotifications.php index b3d9f0ea6..6a63e28ab 100644 --- a/src/notifications/src/HasDatabaseNotifications.php +++ b/src/notifications/src/HasDatabaseNotifications.php @@ -4,8 +4,8 @@ namespace Hypervel\Notifications; -use Hyperf\Database\Model\Relations\MorphMany; -use Hyperf\Database\Query\Builder; +use Hypervel\Database\Eloquent\Relations\MorphMany; +use Hypervel\Database\Query\Builder; trait HasDatabaseNotifications { diff --git a/src/notifications/src/Messages/MailMessage.php b/src/notifications/src/Messages/MailMessage.php index d8c4f3272..b3ecc0049 100644 --- a/src/notifications/src/Messages/MailMessage.php +++ b/src/notifications/src/Messages/MailMessage.php @@ -4,14 +4,14 @@ namespace Hypervel\Notifications\Messages; -use Hyperf\Collection\Collection; use Hyperf\Conditionable\Conditionable; use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Mail\Attachable; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Renderable; use Hypervel\Mail\Attachment; -use Hypervel\Mail\Contracts\Attachable; use Hypervel\Mail\Markdown; -use Hypervel\Support\Contracts\Renderable; +use Hypervel\Support\Collection; class MailMessage extends SimpleMessage implements Renderable { diff --git a/src/notifications/src/Messages/SimpleMessage.php b/src/notifications/src/Messages/SimpleMessage.php index 7219f8ef1..48d99c8e4 100644 --- a/src/notifications/src/Messages/SimpleMessage.php +++ b/src/notifications/src/Messages/SimpleMessage.php @@ -4,8 +4,8 @@ namespace Hypervel\Notifications\Messages; +use Hypervel\Contracts\Support\Htmlable; use Hypervel\Notifications\Action; -use Hypervel\Support\Contracts\Htmlable; class SimpleMessage { diff --git a/src/notifications/src/NotificationSender.php b/src/notifications/src/NotificationSender.php index c2b97a845..1a3bd99fe 100644 --- a/src/notifications/src/NotificationSender.php +++ b/src/notifications/src/NotificationSender.php @@ -4,20 +4,19 @@ namespace Hypervel\Notifications; -use Hyperf\Collection\Collection; -use Hyperf\Database\Model\Collection as ModelCollection; -use Hyperf\Database\Model\Model; -use Hyperf\Stringable\Str; -use Hypervel\Bus\Contracts\Dispatcher as BusDispatcherContract; +use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Contracts\Translation\HasLocalePreference; +use Hypervel\Database\Eloquent\Collection as ModelCollection; +use Hypervel\Database\Eloquent\Model; use Hypervel\Notifications\Events\NotificationSending; use Hypervel\Notifications\Events\NotificationSent; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Hypervel\Support\Traits\Localizable; -use Hypervel\Translation\Contracts\HasLocalePreference; use Psr\EventDispatcher\EventDispatcherInterface; use function Hyperf\Support\value; -use function Hyperf\Tappable\tap; class NotificationSender { diff --git a/src/notifications/src/RoutesNotifications.php b/src/notifications/src/RoutesNotifications.php index 46099c9e3..44dbe4aac 100644 --- a/src/notifications/src/RoutesNotifications.php +++ b/src/notifications/src/RoutesNotifications.php @@ -5,8 +5,8 @@ namespace Hypervel\Notifications; use Hyperf\Context\ApplicationContext; -use Hyperf\Stringable\Str; -use Hypervel\Notifications\Contracts\Dispatcher; +use Hypervel\Contracts\Notifications\Dispatcher; +use Hypervel\Support\Str; trait RoutesNotifications { diff --git a/src/notifications/src/SendQueuedNotifications.php b/src/notifications/src/SendQueuedNotifications.php index b78b3006e..8a9b8f534 100644 --- a/src/notifications/src/SendQueuedNotifications.php +++ b/src/notifications/src/SendQueuedNotifications.php @@ -5,15 +5,15 @@ namespace Hypervel\Notifications; use DateTime; -use Hyperf\Collection\Collection; -use Hyperf\Database\Model\Collection as EloquentCollection; -use Hyperf\Database\Model\Model; use Hypervel\Bus\Queueable; -use Hypervel\Queue\Contracts\ShouldBeEncrypted; -use Hypervel\Queue\Contracts\ShouldQueue; -use Hypervel\Queue\Contracts\ShouldQueueAfterCommit; +use Hypervel\Contracts\Queue\ShouldBeEncrypted; +use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\SerializesModels; +use Hypervel\Support\Collection; use Throwable; class SendQueuedNotifications implements ShouldQueue diff --git a/src/notifications/src/Slack/BlockKit/Blocks/ActionsBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/ActionsBlock.php index cded07dae..8835222e4 100644 --- a/src/notifications/src/Slack/BlockKit/Blocks/ActionsBlock.php +++ b/src/notifications/src/Slack/BlockKit/Blocks/ActionsBlock.php @@ -4,10 +4,10 @@ namespace Hypervel\Notifications\Slack\BlockKit\Blocks; -use Hyperf\Contract\Arrayable; -use Hypervel\Notifications\Contracts\Slack\BlockContract; -use Hypervel\Notifications\Contracts\Slack\ElementContract; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Notifications\Slack\BlockKit\Elements\ButtonElement; +use Hypervel\Notifications\Slack\Contracts\BlockContract; +use Hypervel\Notifications\Slack\Contracts\ElementContract; use InvalidArgumentException; use LogicException; diff --git a/src/notifications/src/Slack/BlockKit/Blocks/ContextBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/ContextBlock.php index 5563a8b33..fcfb4dde2 100644 --- a/src/notifications/src/Slack/BlockKit/Blocks/ContextBlock.php +++ b/src/notifications/src/Slack/BlockKit/Blocks/ContextBlock.php @@ -4,11 +4,11 @@ namespace Hypervel\Notifications\Slack\BlockKit\Blocks; -use Hyperf\Contract\Arrayable; -use Hypervel\Notifications\Contracts\Slack\BlockContract; -use Hypervel\Notifications\Contracts\Slack\ElementContract; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Notifications\Slack\BlockKit\Composites\TextObject; use Hypervel\Notifications\Slack\BlockKit\Elements\ImageElement; +use Hypervel\Notifications\Slack\Contracts\BlockContract; +use Hypervel\Notifications\Slack\Contracts\ElementContract; use InvalidArgumentException; use LogicException; diff --git a/src/notifications/src/Slack/BlockKit/Blocks/DividerBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/DividerBlock.php index b110df866..c970f1cf7 100644 --- a/src/notifications/src/Slack/BlockKit/Blocks/DividerBlock.php +++ b/src/notifications/src/Slack/BlockKit/Blocks/DividerBlock.php @@ -4,7 +4,7 @@ namespace Hypervel\Notifications\Slack\BlockKit\Blocks; -use Hypervel\Notifications\Contracts\Slack\BlockContract; +use Hypervel\Notifications\Slack\Contracts\BlockContract; use InvalidArgumentException; class DividerBlock implements BlockContract diff --git a/src/notifications/src/Slack/BlockKit/Blocks/HeaderBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/HeaderBlock.php index 9d213b4fa..71f6003fd 100644 --- a/src/notifications/src/Slack/BlockKit/Blocks/HeaderBlock.php +++ b/src/notifications/src/Slack/BlockKit/Blocks/HeaderBlock.php @@ -5,8 +5,8 @@ namespace Hypervel\Notifications\Slack\BlockKit\Blocks; use Closure; -use Hypervel\Notifications\Contracts\Slack\BlockContract; use Hypervel\Notifications\Slack\BlockKit\Composites\PlainTextOnlyTextObject; +use Hypervel\Notifications\Slack\Contracts\BlockContract; use InvalidArgumentException; class HeaderBlock implements BlockContract diff --git a/src/notifications/src/Slack/BlockKit/Blocks/ImageBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/ImageBlock.php index 705ad2352..35b1c30cf 100644 --- a/src/notifications/src/Slack/BlockKit/Blocks/ImageBlock.php +++ b/src/notifications/src/Slack/BlockKit/Blocks/ImageBlock.php @@ -4,8 +4,8 @@ namespace Hypervel\Notifications\Slack\BlockKit\Blocks; -use Hypervel\Notifications\Contracts\Slack\BlockContract; use Hypervel\Notifications\Slack\BlockKit\Composites\PlainTextOnlyTextObject; +use Hypervel\Notifications\Slack\Contracts\BlockContract; use InvalidArgumentException; use LogicException; diff --git a/src/notifications/src/Slack/BlockKit/Blocks/SectionBlock.php b/src/notifications/src/Slack/BlockKit/Blocks/SectionBlock.php index adf622806..da75e07d2 100644 --- a/src/notifications/src/Slack/BlockKit/Blocks/SectionBlock.php +++ b/src/notifications/src/Slack/BlockKit/Blocks/SectionBlock.php @@ -4,10 +4,10 @@ namespace Hypervel\Notifications\Slack\BlockKit\Blocks; -use Hyperf\Contract\Arrayable; -use Hypervel\Notifications\Contracts\Slack\BlockContract; -use Hypervel\Notifications\Contracts\Slack\ElementContract; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Notifications\Slack\BlockKit\Composites\TextObject; +use Hypervel\Notifications\Slack\Contracts\BlockContract; +use Hypervel\Notifications\Slack\Contracts\ElementContract; use InvalidArgumentException; use LogicException; diff --git a/src/notifications/src/Slack/BlockKit/Composites/ConfirmObject.php b/src/notifications/src/Slack/BlockKit/Composites/ConfirmObject.php index fde9f67c5..68bba607c 100644 --- a/src/notifications/src/Slack/BlockKit/Composites/ConfirmObject.php +++ b/src/notifications/src/Slack/BlockKit/Composites/ConfirmObject.php @@ -4,7 +4,7 @@ namespace Hypervel\Notifications\Slack\BlockKit\Composites; -use Hypervel\Notifications\Contracts\Slack\ObjectContract; +use Hypervel\Notifications\Slack\Contracts\ObjectContract; class ConfirmObject implements ObjectContract { diff --git a/src/notifications/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php b/src/notifications/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php index fd3f603a1..517936520 100644 --- a/src/notifications/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php +++ b/src/notifications/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php @@ -4,7 +4,7 @@ namespace Hypervel\Notifications\Slack\BlockKit\Composites; -use Hypervel\Notifications\Contracts\Slack\ObjectContract; +use Hypervel\Notifications\Slack\Contracts\ObjectContract; use InvalidArgumentException; class PlainTextOnlyTextObject implements ObjectContract diff --git a/src/notifications/src/Slack/BlockKit/Composites/TextObject.php b/src/notifications/src/Slack/BlockKit/Composites/TextObject.php index 586af74bc..c7df48f30 100644 --- a/src/notifications/src/Slack/BlockKit/Composites/TextObject.php +++ b/src/notifications/src/Slack/BlockKit/Composites/TextObject.php @@ -4,7 +4,7 @@ namespace Hypervel\Notifications\Slack\BlockKit\Composites; -use Hyperf\Collection\Arr; +use Hypervel\Support\Arr; class TextObject extends PlainTextOnlyTextObject { diff --git a/src/notifications/src/Slack/BlockKit/Elements/ButtonElement.php b/src/notifications/src/Slack/BlockKit/Elements/ButtonElement.php index 52ee15822..0337e2ab0 100644 --- a/src/notifications/src/Slack/BlockKit/Elements/ButtonElement.php +++ b/src/notifications/src/Slack/BlockKit/Elements/ButtonElement.php @@ -5,10 +5,10 @@ namespace Hypervel\Notifications\Slack\BlockKit\Elements; use Closure; -use Hyperf\Stringable\Str; -use Hypervel\Notifications\Contracts\Slack\ElementContract; use Hypervel\Notifications\Slack\BlockKit\Composites\ConfirmObject; use Hypervel\Notifications\Slack\BlockKit\Composites\PlainTextOnlyTextObject; +use Hypervel\Notifications\Slack\Contracts\ElementContract; +use Hypervel\Support\Str; use InvalidArgumentException; class ButtonElement implements ElementContract diff --git a/src/notifications/src/Slack/BlockKit/Elements/ImageElement.php b/src/notifications/src/Slack/BlockKit/Elements/ImageElement.php index e2e065ade..378606969 100644 --- a/src/notifications/src/Slack/BlockKit/Elements/ImageElement.php +++ b/src/notifications/src/Slack/BlockKit/Elements/ImageElement.php @@ -4,7 +4,7 @@ namespace Hypervel\Notifications\Slack\BlockKit\Elements; -use Hypervel\Notifications\Contracts\Slack\ElementContract; +use Hypervel\Notifications\Slack\Contracts\ElementContract; use LogicException; class ImageElement implements ElementContract diff --git a/src/notifications/src/Slack/Contracts/BlockContract.php b/src/notifications/src/Slack/Contracts/BlockContract.php new file mode 100644 index 000000000..8d4886f29 --- /dev/null +++ b/src/notifications/src/Slack/Contracts/BlockContract.php @@ -0,0 +1,11 @@ + + */ +abstract class AbstractCursorPaginator implements Htmlable, Stringable +{ + use ForwardsCalls; + use Tappable; + use TransformsToResourceCollection; + + /** + * Render the paginator using the given view. + * + * @param array $data + */ + abstract public function render(?string $view = null, array $data = []): Htmlable; + + /** + * Indicates whether there are more items in the data source. + */ + protected bool $hasMore; + + /** + * All of the items being paginated. + * + * @var Collection + */ + protected Collection $items; + + /** + * The number of items to be shown per page. + */ + protected int $perPage; + + /** + * The base path to assign to all URLs. + */ + protected string $path = '/'; + + /** + * The query parameters to add to all URLs. + * + * @var array + */ + protected array $query = []; + + /** + * The URL fragment to add to all URLs. + */ + protected ?string $fragment = null; + + /** + * The cursor string variable used to store the page. + */ + protected string $cursorName = 'cursor'; + + /** + * The current cursor. + */ + protected ?Cursor $cursor = null; + + /** + * The paginator parameters for the cursor. + * + * @var array + */ + protected array $parameters; + + /** + * The paginator options. + * + * @var array + */ + protected array $options; + + /** + * The current cursor resolver callback. + */ + protected static ?Closure $currentCursorResolver = null; + + /** + * Get the URL for a given cursor. + */ + public function url(?Cursor $cursor): string + { + // If we have any extra query string key / value pairs that need to be added + // onto the URL, we will put them in query string form and then attach it + // to the URL. This allows for extra information like sortings storage. + $parameters = is_null($cursor) ? [] : [$this->cursorName => $cursor->encode()]; + + if (count($this->query) > 0) { + $parameters = array_merge($this->query, $parameters); + } + + return $this->path() + . (str_contains($this->path(), '?') ? '&' : '?') + . Arr::query($parameters) + . $this->buildFragment(); + } + + /** + * Get the URL for the previous page. + */ + public function previousPageUrl(): ?string + { + if (is_null($previousCursor = $this->previousCursor())) { + return null; + } + + return $this->url($previousCursor); + } + + /** + * The URL for the next page, or null. + */ + public function nextPageUrl(): ?string + { + if (is_null($nextCursor = $this->nextCursor())) { + return null; + } + + return $this->url($nextCursor); + } + + /** + * Get the "cursor" that points to the previous set of items. + */ + public function previousCursor(): ?Cursor + { + if (is_null($this->cursor) + || ($this->cursor->pointsToPreviousItems() && ! $this->hasMore)) { + return null; + } + + if ($this->items->isEmpty()) { + return null; + } + + return $this->getCursorForItem($this->items->first(), false); + } + + /** + * Get the "cursor" that points to the next set of items. + */ + public function nextCursor(): ?Cursor + { + if ((is_null($this->cursor) && ! $this->hasMore) + || (! is_null($this->cursor) && $this->cursor->pointsToNextItems() && ! $this->hasMore)) { + return null; + } + + if ($this->items->isEmpty()) { + return null; + } + + return $this->getCursorForItem($this->items->last(), true); + } + + /** + * Get a cursor instance for the given item. + */ + public function getCursorForItem(object $item, bool $isNext = true): Cursor + { + return new Cursor($this->getParametersForItem($item), $isNext); + } + + /** + * Get the cursor parameters for a given object. + * + * @return array + * + * @throws Exception + */ + public function getParametersForItem(object $item): array + { + /** @var Collection $flipped */ + $flipped = (new Collection($this->parameters))->filter()->flip(); + + return $flipped->map(function (int $_, string $parameterName) use ($item) { + if ($item instanceof JsonResource) { + $item = $item->resource; + } + + if ($item instanceof Model + && ! is_null($parameter = $this->getPivotParameterForItem($item, $parameterName))) { + return $parameter; + } + if ($item instanceof ArrayAccess || is_array($item)) { + return $this->ensureParameterIsPrimitive( + $item[$parameterName] ?? $item[Str::afterLast($parameterName, '.')] + ); + } + if (is_object($item)) { + return $this->ensureParameterIsPrimitive( + $item->{$parameterName} ?? $item->{Str::afterLast($parameterName, '.')} + ); + } + + throw new Exception('Only arrays and objects are supported when cursor paginating items.'); + })->toArray(); + } + + /** + * Get the cursor parameter value from a pivot model if applicable. + */ + protected function getPivotParameterForItem(Model $item, string $parameterName): ?string + { + $table = Str::beforeLast($parameterName, '.'); + + foreach ($item->getRelations() as $relation) { + if ($relation instanceof Pivot && $relation->getTable() === $table) { + return $this->ensureParameterIsPrimitive( + $relation->getAttribute(Str::afterLast($parameterName, '.')) + ); + } + } + + return null; + } + + /** + * Ensure the parameter is a primitive type. + * + * This can resolve issues that arise the developer uses a value object for an attribute. + */ + protected function ensureParameterIsPrimitive(mixed $parameter): mixed + { + return is_object($parameter) && method_exists($parameter, '__toString') + ? (string) $parameter + : $parameter; + } + + /** + * Get / set the URL fragment to be appended to URLs. + * + * @return null|$this|string + */ + public function fragment(?string $fragment = null): static|string|null + { + if (is_null($fragment)) { + return $this->fragment; + } + + $this->fragment = $fragment; + + return $this; + } + + /** + * Add a set of query string values to the paginator. + * + * @return $this + */ + public function appends(array|string|null $key, ?string $value = null): static + { + if (is_null($key)) { + return $this; + } + + if (is_array($key)) { + return $this->appendArray($key); + } + + return $this->addQuery($key, $value); + } + + /** + * Add an array of query string values. + * + * @param array $keys + * @return $this + */ + protected function appendArray(array $keys): static + { + foreach ($keys as $key => $value) { + $this->addQuery($key, $value); + } + + return $this; + } + + /** + * Add all current query string values to the paginator. + * + * @return $this + */ + public function withQueryString(): static + { + if (! is_null($query = Paginator::resolveQueryString())) { + return $this->appends($query); + } + + return $this; + } + + /** + * Add a query string value to the paginator. + * + * @return $this + */ + protected function addQuery(string $key, mixed $value): static + { + if ($key !== $this->cursorName) { + $this->query[$key] = $value; + } + + return $this; + } + + /** + * Build the full fragment portion of a URL. + */ + protected function buildFragment(): string + { + return $this->fragment ? '#' . $this->fragment : ''; + } + + /** + * Load a set of relationships onto the mixed relationship collection. + * + * @param array> $relations + * @return $this + */ + public function loadMorph(string $relation, array $relations): static + { + /* @phpstan-ignore method.notFound (loadMorph exists on Eloquent Collection, not base Collection) */ + $this->getCollection()->loadMorph($relation, $relations); + + return $this; + } + + /** + * Load a set of relationship counts onto the mixed relationship collection. + * + * @param array> $relations + * @return $this + */ + public function loadMorphCount(string $relation, array $relations): static + { + /* @phpstan-ignore method.notFound (loadMorphCount exists on Eloquent Collection, not base Collection) */ + $this->getCollection()->loadMorphCount($relation, $relations); + + return $this; + } + + /** + * Get the slice of items being paginated. + * + * @return array + */ + public function items(): array + { + return $this->items->all(); + } + + /** + * Transform each item in the slice of items using a callback. + * + * @template TThroughValue + * + * @param callable(TValue, TKey): TThroughValue $callback + * @return $this + * + * @phpstan-this-out static + */ + public function through(callable $callback): static + { + $this->items->transform($callback); + + return $this; + } + + /** + * Get the number of items shown per page. + */ + public function perPage(): int + { + return $this->perPage; + } + + /** + * Get the current cursor being paginated. + */ + public function cursor(): ?Cursor + { + return $this->cursor; + } + + /** + * Get the query string variable used to store the cursor. + */ + public function getCursorName(): string + { + return $this->cursorName; + } + + /** + * Set the query string variable used to store the cursor. + * + * @return $this + */ + public function setCursorName(string $name): static + { + $this->cursorName = $name; + + return $this; + } + + /** + * Set the base path to assign to all URLs. + * + * @return $this + */ + public function withPath(string $path): static + { + return $this->setPath($path); + } + + /** + * Set the base path to assign to all URLs. + * + * @return $this + */ + public function setPath(string $path): static + { + $this->path = $path; + + return $this; + } + + /** + * Get the base path for paginator generated URLs. + */ + public function path(): ?string + { + return $this->path; + } + + /** + * Resolve the current cursor or return the default value. + */ + public static function resolveCurrentCursor(string $cursorName = 'cursor', ?Cursor $default = null): ?Cursor + { + if (isset(static::$currentCursorResolver)) { + return call_user_func(static::$currentCursorResolver, $cursorName); + } + + return $default; + } + + /** + * Set the current cursor resolver callback. + */ + public static function currentCursorResolver(Closure $resolver): void + { + static::$currentCursorResolver = $resolver; + } + + /** + * Get an instance of the view factory from the resolver. + */ + public static function viewFactory(): mixed + { + return Paginator::viewFactory(); + } + + /** + * Get an iterator for the items. + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return $this->items->getIterator(); + } + + /** + * Determine if the list of items is empty. + */ + public function isEmpty(): bool + { + return $this->items->isEmpty(); + } + + /** + * Determine if the list of items is not empty. + */ + public function isNotEmpty(): bool + { + return $this->items->isNotEmpty(); + } + + /** + * Get the number of items for the current page. + */ + public function count(): int + { + return $this->items->count(); + } + + /** + * Get the paginator's underlying collection. + * + * @return Collection + */ + public function getCollection(): Collection + { + return $this->items; + } + + /** + * Set the paginator's underlying collection. + * + * @template TSetKey of array-key + * @template TSetValue + * + * @param Collection $collection + * @return $this + * + * @phpstan-this-out static + */ + public function setCollection(Collection $collection): static + { + /* @phpstan-ignore assign.propertyType */ + $this->items = $collection; + + return $this; + } + + /** + * Get the paginator options. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Determine if the given item exists. + * + * @param TKey $key + */ + public function offsetExists($key): bool + { + return $this->items->has($key); + } + + /** + * Get the item at the given offset. + * + * @param TKey $key + * @return null|TValue + */ + public function offsetGet($key): mixed + { + return $this->items->get($key); + } + + /** + * Set the item at the given offset. + * + * @param null|TKey $key + * @param TValue $value + */ + public function offsetSet($key, $value): void + { + $this->items->put($key, $value); + } + + /** + * Unset the item at the given key. + * + * @param TKey $key + */ + public function offsetUnset($key): void + { + $this->items->forget($key); + } + + /** + * Render the contents of the paginator to HTML. + */ + public function toHtml(): string + { + return (string) $this->render(); + } + + /** + * Make dynamic calls into the collection. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->forwardCallTo($this->getCollection(), $method, $parameters); + } + + /** + * Render the contents of the paginator when casting to a string. + */ + public function __toString(): string + { + return (string) $this->render(); + } +} diff --git a/src/pagination/src/AbstractPaginator.php b/src/pagination/src/AbstractPaginator.php new file mode 100644 index 000000000..d0337aaeb --- /dev/null +++ b/src/pagination/src/AbstractPaginator.php @@ -0,0 +1,725 @@ + + */ +abstract class AbstractPaginator implements CanBeEscapedWhenCastToString, Htmlable, Stringable +{ + use ForwardsCalls; + use Tappable; + use TransformsToResourceCollection; + + /** + * Render the paginator using the given view. + * + * @param array $data + */ + abstract public function render(?string $view = null, array $data = []): Htmlable; + + /** + * Determine if there are more items in the data source. + */ + abstract public function hasMorePages(): bool; + + /** + * All of the items being paginated. + * + * @var Collection + */ + protected Collection $items; + + /** + * The number of items to be shown per page. + */ + protected int $perPage; + + /** + * The current page being "viewed". + */ + protected int $currentPage; + + /** + * The base path to assign to all URLs. + */ + protected string $path = '/'; + + /** + * The query parameters to add to all URLs. + * + * @var array + */ + protected array $query = []; + + /** + * The URL fragment to add to all URLs. + */ + protected ?string $fragment = null; + + /** + * The query string variable used to store the page. + */ + protected string $pageName = 'page'; + + /** + * Indicates that the paginator's string representation should be escaped when __toString is invoked. + */ + protected bool $escapeWhenCastingToString = false; + + /** + * The number of links to display on each side of current page link. + */ + public int $onEachSide = 3; + + /** + * The paginator options. + * + * @var array + */ + protected array $options = []; + + /** + * The current path resolver callback. + */ + protected static ?Closure $currentPathResolver = null; + + /** + * The current page resolver callback. + */ + protected static ?Closure $currentPageResolver = null; + + /** + * The query string resolver callback. + */ + protected static ?Closure $queryStringResolver = null; + + /** + * The view factory resolver callback. + */ + protected static ?Closure $viewFactoryResolver = null; + + /** + * The default pagination view. + */ + public static string $defaultView = 'pagination::tailwind'; + + /** + * The default "simple" pagination view. + */ + public static string $defaultSimpleView = 'pagination::simple-tailwind'; + + /** + * Determine if the given value is a valid page number. + */ + protected function isValidPageNumber(int $page): bool + { + return $page >= 1; + } + + /** + * Get the URL for the previous page. + */ + public function previousPageUrl(): ?string + { + if ($this->currentPage() > 1) { + return $this->url($this->currentPage() - 1); + } + + return null; + } + + /** + * Create a range of pagination URLs. + * + * @return array + */ + public function getUrlRange(int $start, int $end): array + { + return Collection::range($start, $end) + ->mapWithKeys(fn ($page) => [$page => $this->url($page)]) + ->all(); + } + + /** + * Get the URL for a given page number. + */ + public function url(int $page): string + { + if ($page <= 0) { + $page = 1; + } + + // If we have any extra query string key / value pairs that need to be added + // onto the URL, we will put them in query string form and then attach it + // to the URL. This allows for extra information like sortings storage. + $parameters = [$this->pageName => $page]; + + if (count($this->query) > 0) { + $parameters = array_merge($this->query, $parameters); + } + + return $this->path() + . (str_contains($this->path(), '?') ? '&' : '?') + . Arr::query($parameters) + . $this->buildFragment(); + } + + /** + * Get / set the URL fragment to be appended to URLs. + * + * @return null|$this|string + */ + public function fragment(?string $fragment = null): static|string|null + { + if (is_null($fragment)) { + return $this->fragment; + } + + $this->fragment = $fragment; + + return $this; + } + + /** + * Add a set of query string values to the paginator. + * + * @return $this + */ + public function appends(array|string|null $key, ?string $value = null): static + { + if (is_null($key)) { + return $this; + } + + if (is_array($key)) { + return $this->appendArray($key); + } + + return $this->addQuery($key, $value); + } + + /** + * Add an array of query string values. + * + * @param array $keys + * @return $this + */ + protected function appendArray(array $keys): static + { + foreach ($keys as $key => $value) { + $this->addQuery($key, $value); + } + + return $this; + } + + /** + * Add all current query string values to the paginator. + * + * @return $this + */ + public function withQueryString(): static + { + if (isset(static::$queryStringResolver)) { + return $this->appends(call_user_func(static::$queryStringResolver)); + } + + return $this; + } + + /** + * Add a query string value to the paginator. + * + * @return $this + */ + protected function addQuery(string $key, mixed $value): static + { + if ($key !== $this->pageName) { + $this->query[$key] = $value; + } + + return $this; + } + + /** + * Build the full fragment portion of a URL. + */ + protected function buildFragment(): string + { + return $this->fragment ? '#' . $this->fragment : ''; + } + + /** + * Load a set of relationships onto the mixed relationship collection. + * + * @param array> $relations + * @return $this + */ + public function loadMorph(string $relation, array $relations): static + { + /* @phpstan-ignore method.notFound (loadMorph exists on Eloquent Collection, not base Collection) */ + $this->getCollection()->loadMorph($relation, $relations); + + return $this; + } + + /** + * Load a set of relationship counts onto the mixed relationship collection. + * + * @param array> $relations + * @return $this + */ + public function loadMorphCount(string $relation, array $relations): static + { + /* @phpstan-ignore method.notFound (loadMorphCount exists on Eloquent Collection, not base Collection) */ + $this->getCollection()->loadMorphCount($relation, $relations); + + return $this; + } + + /** + * Get the slice of items being paginated. + * + * @return array + */ + public function items(): array + { + return $this->items->all(); + } + + /** + * Get the number of the first item in the slice. + */ + public function firstItem(): ?int + { + return count($this->items) > 0 ? ($this->currentPage - 1) * $this->perPage + 1 : null; + } + + /** + * Get the number of the last item in the slice. + */ + public function lastItem(): ?int + { + return count($this->items) > 0 ? $this->firstItem() + $this->count() - 1 : null; + } + + /** + * Transform each item in the slice of items using a callback. + * + * @template TMapValue + * + * @param callable(TValue, TKey): TMapValue $callback + * @return $this + * + * @phpstan-this-out static + */ + public function through(callable $callback): static + { + $this->items->transform($callback); + + return $this; + } + + /** + * Get the number of items shown per page. + */ + public function perPage(): int + { + return $this->perPage; + } + + /** + * Determine if there are enough items to split into multiple pages. + */ + public function hasPages(): bool + { + return $this->currentPage() != 1 || $this->hasMorePages(); + } + + /** + * Determine if the paginator is on the first page. + */ + public function onFirstPage(): bool + { + return $this->currentPage() <= 1; + } + + /** + * Determine if the paginator is on the last page. + */ + public function onLastPage(): bool + { + return ! $this->hasMorePages(); + } + + /** + * Get the current page. + */ + public function currentPage(): int + { + return $this->currentPage; + } + + /** + * Get the query string variable used to store the page. + */ + public function getPageName(): string + { + return $this->pageName; + } + + /** + * Set the query string variable used to store the page. + * + * @return $this + */ + public function setPageName(string $name): static + { + $this->pageName = $name; + + return $this; + } + + /** + * Set the base path to assign to all URLs. + * + * @return $this + */ + public function withPath(string $path): static + { + return $this->setPath($path); + } + + /** + * Set the base path to assign to all URLs. + * + * @return $this + */ + public function setPath(string $path): static + { + $this->path = $path; + + return $this; + } + + /** + * Set the number of links to display on each side of current page link. + * + * @return $this + */ + public function onEachSide(int $count): static + { + $this->onEachSide = $count; + + return $this; + } + + /** + * Get the base path for paginator generated URLs. + */ + public function path(): ?string + { + return $this->path; + } + + /** + * Resolve the current request path or return the default value. + */ + public static function resolveCurrentPath(string $default = '/'): string + { + if (isset(static::$currentPathResolver)) { + return call_user_func(static::$currentPathResolver); + } + + return $default; + } + + /** + * Set the current request path resolver callback. + */ + public static function currentPathResolver(Closure $resolver): void + { + static::$currentPathResolver = $resolver; + } + + /** + * Resolve the current page or return the default value. + */ + public static function resolveCurrentPage(string $pageName = 'page', int $default = 1): int + { + if (isset(static::$currentPageResolver)) { + return (int) call_user_func(static::$currentPageResolver, $pageName); + } + + return $default; + } + + /** + * Set the current page resolver callback. + */ + public static function currentPageResolver(Closure $resolver): void + { + static::$currentPageResolver = $resolver; + } + + /** + * Resolve the query string or return the default value. + */ + public static function resolveQueryString(string|array|null $default = null): string|array|null + { + if (isset(static::$queryStringResolver)) { + return (static::$queryStringResolver)(); + } + + return $default; + } + + /** + * Set with query string resolver callback. + */ + public static function queryStringResolver(Closure $resolver): void + { + static::$queryStringResolver = $resolver; + } + + /** + * Get an instance of the view factory from the resolver. + */ + public static function viewFactory(): mixed + { + return call_user_func(static::$viewFactoryResolver); + } + + /** + * Set the view factory resolver callback. + */ + public static function viewFactoryResolver(Closure $resolver): void + { + static::$viewFactoryResolver = $resolver; + } + + /** + * Set the default pagination view. + */ + public static function defaultView(string $view): void + { + static::$defaultView = $view; + } + + /** + * Set the default "simple" pagination view. + */ + public static function defaultSimpleView(string $view): void + { + static::$defaultSimpleView = $view; + } + + /** + * Indicate that Tailwind styling should be used for generated links. + */ + public static function useTailwind(): void + { + static::defaultView('pagination::tailwind'); + static::defaultSimpleView('pagination::simple-tailwind'); + } + + /** + * Indicate that Bootstrap 4 styling should be used for generated links. + */ + public static function useBootstrap(): void + { + static::useBootstrapFour(); + } + + /** + * Indicate that Bootstrap 3 styling should be used for generated links. + */ + public static function useBootstrapThree(): void + { + static::defaultView('pagination::default'); + static::defaultSimpleView('pagination::simple-default'); + } + + /** + * Indicate that Bootstrap 4 styling should be used for generated links. + */ + public static function useBootstrapFour(): void + { + static::defaultView('pagination::bootstrap-4'); + static::defaultSimpleView('pagination::simple-bootstrap-4'); + } + + /** + * Indicate that Bootstrap 5 styling should be used for generated links. + */ + public static function useBootstrapFive(): void + { + static::defaultView('pagination::bootstrap-5'); + static::defaultSimpleView('pagination::simple-bootstrap-5'); + } + + /** + * Get an iterator for the items. + * + * @return Traversable + */ + public function getIterator(): Traversable + { + return $this->items->getIterator(); + } + + /** + * Determine if the list of items is empty. + */ + public function isEmpty(): bool + { + return $this->items->isEmpty(); + } + + /** + * Determine if the list of items is not empty. + */ + public function isNotEmpty(): bool + { + return $this->items->isNotEmpty(); + } + + /** + * Get the number of items for the current page. + */ + public function count(): int + { + return $this->items->count(); + } + + /** + * Get the paginator's underlying collection. + * + * @return Collection + */ + public function getCollection(): Collection + { + return $this->items; + } + + /** + * Set the paginator's underlying collection. + * + * @param Collection $collection + * @return $this + */ + public function setCollection(Collection $collection): static + { + $this->items = $collection; + + return $this; + } + + /** + * Get the paginator options. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Determine if the given item exists. + * + * @param TKey $key + */ + public function offsetExists($key): bool + { + return $this->items->has($key); + } + + /** + * Get the item at the given offset. + * + * @param TKey $key + * @return null|TValue + */ + public function offsetGet($key): mixed + { + return $this->items->get($key); + } + + /** + * Set the item at the given offset. + * + * @param null|TKey $key + * @param TValue $value + */ + public function offsetSet($key, $value): void + { + $this->items->put($key, $value); + } + + /** + * Unset the item at the given key. + * + * @param TKey $key + */ + public function offsetUnset($key): void + { + $this->items->forget($key); + } + + /** + * Render the contents of the paginator to HTML. + */ + public function toHtml(): string + { + return (string) $this->render(); + } + + /** + * Make dynamic calls into the collection. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->forwardCallTo($this->getCollection(), $method, $parameters); + } + + /** + * Render the contents of the paginator when casting to a string. + */ + public function __toString(): string + { + return $this->escapeWhenCastingToString + ? e((string) $this->render()) + : (string) $this->render(); + } + + /** + * Indicate that the paginator's string representation should be escaped when __toString is invoked. + * + * @return $this + */ + public function escapeWhenCastingToString(bool $escape = true): static + { + $this->escapeWhenCastingToString = $escape; + + return $this; + } +} diff --git a/src/pagination/src/Cursor.php b/src/pagination/src/Cursor.php new file mode 100644 index 000000000..ee935c035 --- /dev/null +++ b/src/pagination/src/Cursor.php @@ -0,0 +1,110 @@ + */ +class Cursor implements Arrayable +{ + /** + * Create a new cursor instance. + * + * @param array $parameters the parameters associated with the cursor + * @param bool $pointsToNextItems determine whether the cursor points to the next or previous set of items + */ + public function __construct( + protected array $parameters, + protected bool $pointsToNextItems = true, + ) { + } + + /** + * Get the given parameter from the cursor. + * + * @throws UnexpectedValueException + */ + public function parameter(string $parameterName): string|int|null + { + if (! array_key_exists($parameterName, $this->parameters)) { + throw new UnexpectedValueException("Unable to find parameter [{$parameterName}] in pagination item."); + } + + return $this->parameters[$parameterName]; + } + + /** + * Get the given parameters from the cursor. + * + * @param array $parameterNames + * @return array + */ + public function parameters(array $parameterNames): array + { + return (new Collection($parameterNames)) + ->map(fn ($parameterName) => $this->parameter($parameterName)) + ->toArray(); + } + + /** + * Determine whether the cursor points to the next set of items. + */ + public function pointsToNextItems(): bool + { + return $this->pointsToNextItems; + } + + /** + * Determine whether the cursor points to the previous set of items. + */ + public function pointsToPreviousItems(): bool + { + return ! $this->pointsToNextItems; + } + + /** + * Get the array representation of the cursor. + * + * @return array + */ + public function toArray(): array + { + return array_merge($this->parameters, [ + '_pointsToNextItems' => $this->pointsToNextItems, + ]); + } + + /** + * Get the encoded string representation of the cursor to construct a URL. + */ + public function encode(): string + { + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(json_encode($this->toArray()))); + } + + /** + * Get a cursor instance from the encoded string representation. + */ + public static function fromEncoded(?string $encodedString): ?static + { + if ($encodedString === null) { + return null; + } + + $parameters = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $encodedString)), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + $pointsToNextItems = $parameters['_pointsToNextItems']; + + unset($parameters['_pointsToNextItems']); + + return new static($parameters, $pointsToNextItems); + } +} diff --git a/src/pagination/src/CursorPaginator.php b/src/pagination/src/CursorPaginator.php new file mode 100644 index 000000000..0bcdbe6c2 --- /dev/null +++ b/src/pagination/src/CursorPaginator.php @@ -0,0 +1,174 @@ + + * + * @implements Arrayable + * @implements ArrayAccess + * @implements IteratorAggregate + * @implements PaginatorContract + */ +class CursorPaginator extends AbstractCursorPaginator implements Arrayable, ArrayAccess, Countable, IteratorAggregate, Jsonable, JsonSerializable, PaginatorContract +{ + /** + * Indicates whether there are more items in the data source. + */ + protected bool $hasMore; + + /** + * Create a new paginator instance. + * + * @param null|Arrayable|Collection|iterable $items + * @param array $options (path, query, fragment, pageName) + */ + public function __construct(mixed $items, int $perPage, ?Cursor $cursor = null, array $options = []) + { + $this->options = $options; + + foreach ($options as $key => $value) { + $this->{$key} = $value; + } + + $this->perPage = $perPage; + $this->cursor = $cursor; + $this->path = $this->path !== '/' ? rtrim($this->path, '/') : $this->path; + + $this->setItems($items); + } + + /** + * Set the items for the paginator. + * + * @param null|Arrayable|Collection|iterable $items + */ + protected function setItems(mixed $items): void + { + $this->items = $items instanceof Collection ? $items : new Collection($items); + + $this->hasMore = $this->items->count() > $this->perPage; + + $this->items = $this->items->slice(0, $this->perPage); + + if (! is_null($this->cursor) && $this->cursor->pointsToPreviousItems()) { + $this->items = $this->items->reverse()->values(); + } + } + + /** + * Render the paginator using the given view. + * + * @param array $data + */ + public function links(?string $view = null, array $data = []): Htmlable + { + return $this->render($view, $data); + } + + /** + * Render the paginator using the given view. + * + * @param array $data + */ + public function render(?string $view = null, array $data = []): Htmlable + { + return static::viewFactory()->make($view ?: Paginator::$defaultSimpleView, array_merge($data, [ + 'paginator' => $this, + ])); + } + + /** + * Determine if there are more items in the data source. + */ + public function hasMorePages(): bool + { + return (is_null($this->cursor) && $this->hasMore) + || (! is_null($this->cursor) && $this->cursor->pointsToNextItems() && $this->hasMore) + || (! is_null($this->cursor) && $this->cursor->pointsToPreviousItems()); + } + + /** + * Determine if there are enough items to split into multiple pages. + */ + public function hasPages(): bool + { + return ! $this->onFirstPage() || $this->hasMorePages(); + } + + /** + * Determine if the paginator is on the first page. + */ + public function onFirstPage(): bool + { + return is_null($this->cursor) || ($this->cursor->pointsToPreviousItems() && ! $this->hasMore); + } + + /** + * Determine if the paginator is on the last page. + */ + public function onLastPage(): bool + { + return ! $this->hasMorePages(); + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'data' => $this->items->toArray(), + 'path' => $this->path(), + 'per_page' => $this->perPage(), + 'next_cursor' => $this->nextCursor()?->encode(), + 'next_page_url' => $this->nextPageUrl(), + 'prev_cursor' => $this->previousCursor()?->encode(), + 'prev_page_url' => $this->previousPageUrl(), + ]; + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Convert the object to its JSON representation. + */ + public function toJson(int $options = 0): string + { + return json_encode($this->jsonSerialize(), $options); + } + + /** + * Convert the object to pretty print formatted JSON. + */ + public function toPrettyJson(int $options = 0): string + { + return $this->toJson(JSON_PRETTY_PRINT | $options); + } +} diff --git a/src/pagination/src/LengthAwarePaginator.php b/src/pagination/src/LengthAwarePaginator.php new file mode 100644 index 000000000..94f88cb05 --- /dev/null +++ b/src/pagination/src/LengthAwarePaginator.php @@ -0,0 +1,233 @@ + + * + * @implements Arrayable + * @implements ArrayAccess + * @implements IteratorAggregate + * @implements LengthAwarePaginatorContract + */ +class LengthAwarePaginator extends AbstractPaginator implements Arrayable, ArrayAccess, Countable, IteratorAggregate, Jsonable, JsonSerializable, LengthAwarePaginatorContract +{ + /** + * The total number of items before slicing. + */ + protected int $total; + + /** + * The last available page. + */ + protected int $lastPage; + + /** + * Create a new paginator instance. + * + * @param null|Arrayable|Collection|iterable $items + * @param array $options (path, query, fragment, pageName) + */ + public function __construct(mixed $items, int $total, int $perPage, ?int $currentPage = null, array $options = []) + { + $this->options = $options; + + foreach ($options as $key => $value) { + $this->{$key} = $value; + } + + $this->total = $total; + $this->perPage = $perPage; + $this->lastPage = max((int) ceil($total / $perPage), 1); + $this->path = $this->path !== '/' ? rtrim($this->path, '/') : $this->path; + $this->currentPage = $this->setCurrentPage($currentPage, $this->pageName); + $this->items = $items instanceof Collection ? $items : new Collection($items); + } + + /** + * Get the current page for the request. + */ + protected function setCurrentPage(?int $currentPage, string $pageName): int + { + $currentPage = $currentPage ?: static::resolveCurrentPage($pageName); + + return $this->isValidPageNumber($currentPage) ? (int) $currentPage : 1; + } + + /** + * Render the paginator using the given view. + * + * @param array $data + */ + public function links(?string $view = null, array $data = []): Htmlable + { + return $this->render($view, $data); + } + + /** + * Render the paginator using the given view. + * + * @param array $data + */ + public function render(?string $view = null, array $data = []): Htmlable + { + return static::viewFactory()->make($view ?: static::$defaultView, array_merge($data, [ + 'paginator' => $this, + 'elements' => $this->elements(), + ])); + } + + /** + * Get the paginator links as a collection (for JSON responses). + * + * @return Collection> + */ + public function linkCollection(): Collection + { + /** @var Collection> */ + return (new Collection($this->elements()))->flatMap(function ($item) { + if (! is_array($item)) { + return [['url' => null, 'label' => '...', 'active' => false]]; + } + + return (new Collection($item))->map(function ($url, $page) { + return [ + 'url' => $url, + 'label' => (string) $page, + 'page' => $page, + 'active' => $this->currentPage() === $page, + ]; + }); + })->prepend([ + 'url' => $this->previousPageUrl(), + 'label' => function_exists('__') ? __('pagination.previous') : 'Previous', + 'page' => $this->currentPage() > 1 ? $this->currentPage() - 1 : null, + 'active' => false, + ])->push([ + 'url' => $this->nextPageUrl(), + 'label' => function_exists('__') ? __('pagination.next') : 'Next', + 'page' => $this->hasMorePages() ? $this->currentPage() + 1 : null, + 'active' => false, + ]); + } + + /** + * Get the array of elements to pass to the view. + * + * @return array + */ + protected function elements(): array + { + $window = UrlWindow::make($this); + + return array_filter([ + $window['first'], + is_array($window['slider']) ? '...' : null, + $window['slider'], + is_array($window['last']) ? '...' : null, + $window['last'], + ]); + } + + /** + * Get the total number of items being paginated. + */ + public function total(): int + { + return $this->total; + } + + /** + * Determine if there are more items in the data source. + */ + public function hasMorePages(): bool + { + return $this->currentPage() < $this->lastPage(); + } + + /** + * Get the URL for the next page. + */ + public function nextPageUrl(): ?string + { + if ($this->hasMorePages()) { + return $this->url($this->currentPage() + 1); + } + + return null; + } + + /** + * Get the last page. + */ + public function lastPage(): int + { + return $this->lastPage; + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'current_page' => $this->currentPage(), + 'data' => $this->items->toArray(), + 'first_page_url' => $this->url(1), + 'from' => $this->firstItem(), + 'last_page' => $this->lastPage(), + 'last_page_url' => $this->url($this->lastPage()), + 'links' => $this->linkCollection()->toArray(), + 'next_page_url' => $this->nextPageUrl(), + 'path' => $this->path(), + 'per_page' => $this->perPage(), + 'prev_page_url' => $this->previousPageUrl(), + 'to' => $this->lastItem(), + 'total' => $this->total(), + ]; + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Convert the object to its JSON representation. + */ + public function toJson(int $options = 0): string + { + return json_encode($this->jsonSerialize(), $options); + } + + /** + * Convert the object to pretty print formatted JSON. + */ + public function toPrettyJson(int $options = 0): string + { + return $this->toJson(JSON_PRETTY_PRINT | $options); + } +} diff --git a/src/pagination/src/PaginationServiceProvider.php b/src/pagination/src/PaginationServiceProvider.php new file mode 100755 index 000000000..9c82c2b8d --- /dev/null +++ b/src/pagination/src/PaginationServiceProvider.php @@ -0,0 +1,18 @@ +app); + } +} diff --git a/src/pagination/src/PaginationState.php b/src/pagination/src/PaginationState.php new file mode 100644 index 000000000..c3bb72df4 --- /dev/null +++ b/src/pagination/src/PaginationState.php @@ -0,0 +1,58 @@ + $app->get('view')); + + Paginator::currentPathResolver(function () use ($app): string { + if (! Context::has(ServerRequestInterface::class)) { + return '/'; + } + + return $app->get('request')->url(); + }); + + Paginator::currentPageResolver(function (string $pageName = 'page') use ($app): int { + if (! Context::has(ServerRequestInterface::class)) { + return 1; + } + + $page = $app->get('request')->input($pageName); + + if (filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1) { + return (int) $page; + } + + return 1; + }); + + Paginator::queryStringResolver(function () use ($app): array { + if (! Context::has(ServerRequestInterface::class)) { + return []; + } + + return $app->get('request')->query(); + }); + + CursorPaginator::currentCursorResolver(function (string $cursorName = 'cursor') use ($app): ?Cursor { + if (! Context::has(ServerRequestInterface::class)) { + return null; + } + + return Cursor::fromEncoded($app->get('request')->input($cursorName)); + }); + } +} diff --git a/src/pagination/src/Paginator.php b/src/pagination/src/Paginator.php new file mode 100644 index 000000000..0abaef8df --- /dev/null +++ b/src/pagination/src/Paginator.php @@ -0,0 +1,181 @@ + + * + * @implements Arrayable + * @implements ArrayAccess + * @implements IteratorAggregate + * @implements PaginatorContract + */ +class Paginator extends AbstractPaginator implements Arrayable, ArrayAccess, Countable, IteratorAggregate, Jsonable, JsonSerializable, PaginatorContract +{ + /** + * Determine if there are more items in the data source. + */ + protected bool $hasMore; + + /** + * Create a new paginator instance. + * + * @param Arrayable|Collection|iterable $items + * @param array $options (path, query, fragment, pageName) + */ + public function __construct(mixed $items, int $perPage, ?int $currentPage = null, array $options = []) + { + $this->options = $options; + + foreach ($options as $key => $value) { + $this->{$key} = $value; + } + + $this->perPage = $perPage; + $this->currentPage = $this->setCurrentPage($currentPage); + $this->path = $this->path !== '/' ? rtrim($this->path, '/') : $this->path; + + $this->setItems($items); + } + + /** + * Get the current page for the request. + */ + protected function setCurrentPage(?int $currentPage): int + { + $currentPage = $currentPage ?: static::resolveCurrentPage(); + + return $this->isValidPageNumber($currentPage) ? (int) $currentPage : 1; + } + + /** + * Set the items for the paginator. + * + * @param null|Arrayable|Collection|iterable $items + */ + protected function setItems(mixed $items): void + { + $this->items = $items instanceof Collection ? $items : new Collection($items); + + $this->hasMore = $this->items->count() > $this->perPage; + + $this->items = $this->items->slice(0, $this->perPage); + } + + /** + * Get the URL for the next page. + */ + public function nextPageUrl(): ?string + { + if ($this->hasMorePages()) { + return $this->url($this->currentPage() + 1); + } + + return null; + } + + /** + * Render the paginator using the given view. + * + * @param array $data + */ + public function links(?string $view = null, array $data = []): Htmlable + { + return $this->render($view, $data); + } + + /** + * Render the paginator using the given view. + * + * @param array $data + */ + public function render(?string $view = null, array $data = []): Htmlable + { + return static::viewFactory()->make($view ?: static::$defaultSimpleView, array_merge($data, [ + 'paginator' => $this, + ])); + } + + /** + * Manually indicate that the paginator does have more pages. + * + * @return $this + */ + public function hasMorePagesWhen(bool $hasMore = true): static + { + $this->hasMore = $hasMore; + + return $this; + } + + /** + * Determine if there are more items in the data source. + */ + public function hasMorePages(): bool + { + return $this->hasMore; + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'current_page' => $this->currentPage(), + 'current_page_url' => $this->url($this->currentPage()), + 'data' => $this->items->toArray(), + 'first_page_url' => $this->url(1), + 'from' => $this->firstItem(), + 'next_page_url' => $this->nextPageUrl(), + 'path' => $this->path(), + 'per_page' => $this->perPage(), + 'prev_page_url' => $this->previousPageUrl(), + 'to' => $this->lastItem(), + ]; + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Convert the object to its JSON representation. + */ + public function toJson(int $options = 0): string + { + return json_encode($this->jsonSerialize(), $options); + } + + /** + * Convert the object to pretty print formatted JSON. + */ + public function toPrettyJson(int $options = 0): string + { + return $this->toJson(JSON_PRETTY_PRINT | $options); + } +} diff --git a/src/pagination/src/UrlWindow.php b/src/pagination/src/UrlWindow.php new file mode 100644 index 000000000..8c0b73525 --- /dev/null +++ b/src/pagination/src/UrlWindow.php @@ -0,0 +1,204 @@ +paginator = $paginator; + } + + /** + * Create a new URL window instance. + * + * @return array + */ + public static function make(PaginatorContract $paginator): array + { + return (new static($paginator))->get(); + } + + /** + * Get the window of URLs to be shown. + * + * @return array + */ + public function get(): array + { + /** @phpstan-ignore property.notFound (onEachSide is a public property on the concrete class) */ + $onEachSide = $this->paginator->onEachSide; + + if ($this->paginator->lastPage() < ($onEachSide * 2) + 8) { + return $this->getSmallSlider(); + } + + return $this->getUrlSlider($onEachSide); + } + + /** + * Get the slider of URLs there are not enough pages to slide. + * + * @return array + */ + protected function getSmallSlider(): array + { + return [ + 'first' => $this->paginator->getUrlRange(1, $this->lastPage()), + 'slider' => null, + 'last' => null, + ]; + } + + /** + * Create a URL slider links. + * + * @return array + */ + protected function getUrlSlider(int $onEachSide): array + { + $window = $onEachSide + 4; + + if (! $this->hasPages()) { + return ['first' => null, 'slider' => null, 'last' => null]; + } + + // If the current page is very close to the beginning of the page range, we will + // just render the beginning of the page range, followed by the last 2 of the + // links in this list, since we will not have room to create a full slider. + if ($this->currentPage() <= $window) { + return $this->getSliderTooCloseToBeginning($window, $onEachSide); + } + + // If the current page is close to the ending of the page range we will just get + // this first couple pages, followed by a larger window of these ending pages + // since we're too close to the end of the list to create a full on slider. + if ($this->currentPage() > ($this->lastPage() - $window)) { + return $this->getSliderTooCloseToEnding($window, $onEachSide); + } + + // If we have enough room on both sides of the current page to build a slider we + // will surround it with both the beginning and ending caps, with this window + // of pages in the middle providing a Google style sliding paginator setup. + return $this->getFullSlider($onEachSide); + } + + /** + * Get the slider of URLs when too close to the beginning of the window. + * + * @return array + */ + protected function getSliderTooCloseToBeginning(int $window, int $onEachSide): array + { + return [ + 'first' => $this->paginator->getUrlRange(1, $window + $onEachSide), + 'slider' => null, + 'last' => $this->getFinish(), + ]; + } + + /** + * Get the slider of URLs when too close to the ending of the window. + * + * @return array + */ + protected function getSliderTooCloseToEnding(int $window, int $onEachSide): array + { + $last = $this->paginator->getUrlRange( + $this->lastPage() - ($window + ($onEachSide - 1)), + $this->lastPage() + ); + + return [ + 'first' => $this->getStart(), + 'slider' => null, + 'last' => $last, + ]; + } + + /** + * Get the slider of URLs when a full slider can be made. + * + * @return array + */ + protected function getFullSlider(int $onEachSide): array + { + return [ + 'first' => $this->getStart(), + 'slider' => $this->getAdjacentUrlRange($onEachSide), + 'last' => $this->getFinish(), + ]; + } + + /** + * Get the page range for the current page window. + * + * @return array + */ + public function getAdjacentUrlRange(int $onEachSide): array + { + return $this->paginator->getUrlRange( + $this->currentPage() - $onEachSide, + $this->currentPage() + $onEachSide + ); + } + + /** + * Get the starting URLs of a pagination slider. + * + * @return array + */ + public function getStart(): array + { + return $this->paginator->getUrlRange(1, 2); + } + + /** + * Get the ending URLs of a pagination slider. + * + * @return array + */ + public function getFinish(): array + { + return $this->paginator->getUrlRange( + $this->lastPage() - 1, + $this->lastPage() + ); + } + + /** + * Determine if the underlying paginator being presented has pages to show. + */ + public function hasPages(): bool + { + return $this->paginator->lastPage() > 1; + } + + /** + * Get the current page from the paginator. + */ + protected function currentPage(): int + { + return $this->paginator->currentPage(); + } + + /** + * Get the last page from the paginator. + */ + protected function lastPage(): int + { + return $this->paginator->lastPage(); + } +} diff --git a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php index 666a2cee6..fcbe28efe 100644 --- a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php +++ b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; use function Hypervel\Config\config; @@ -12,7 +12,7 @@ /** * Get the migration connection name. */ - public function getConnection(): string + public function getConnection(): ?string { return config('permission.storage.database.connection') ?: parent::getConnection(); diff --git a/src/permission/src/Contracts/Factory.php b/src/permission/src/Contracts/Factory.php index 106ae7afb..a7a5b513d 100644 --- a/src/permission/src/Contracts/Factory.php +++ b/src/permission/src/Contracts/Factory.php @@ -4,7 +4,7 @@ namespace Hypervel\Permission\Contracts; -use Hypervel\Cache\Contracts\Repository; +use Hypervel\Contracts\Cache\Repository; interface Factory { diff --git a/src/permission/src/Contracts/Permission.php b/src/permission/src/Contracts/Permission.php index 543e6f747..7e413d448 100644 --- a/src/permission/src/Contracts/Permission.php +++ b/src/permission/src/Contracts/Permission.php @@ -4,7 +4,7 @@ namespace Hypervel\Permission\Contracts; -use Hyperf\Database\Model\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; /** * @mixin \Hypervel\Permission\Models\Permission diff --git a/src/permission/src/Contracts/Role.php b/src/permission/src/Contracts/Role.php index 314b93034..e114aa5a9 100644 --- a/src/permission/src/Contracts/Role.php +++ b/src/permission/src/Contracts/Role.php @@ -4,7 +4,7 @@ namespace Hypervel\Permission\Contracts; -use Hyperf\Database\Model\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; /** * @mixin \Hypervel\Permission\Models\Role diff --git a/src/permission/src/Middlewares/PermissionMiddleware.php b/src/permission/src/Middlewares/PermissionMiddleware.php index cff08432b..ab543a3e8 100644 --- a/src/permission/src/Middlewares/PermissionMiddleware.php +++ b/src/permission/src/Middlewares/PermissionMiddleware.php @@ -5,11 +5,11 @@ namespace Hypervel\Permission\Middlewares; use BackedEnum; -use Hyperf\Collection\Collection; use Hyperf\Contract\ContainerInterface; use Hypervel\Auth\AuthManager; use Hypervel\Permission\Exceptions\PermissionException; use Hypervel\Permission\Exceptions\UnauthorizedException; +use Hypervel\Support\Collection; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; diff --git a/src/permission/src/Middlewares/RoleMiddleware.php b/src/permission/src/Middlewares/RoleMiddleware.php index 70efcdfbd..44def5442 100644 --- a/src/permission/src/Middlewares/RoleMiddleware.php +++ b/src/permission/src/Middlewares/RoleMiddleware.php @@ -5,11 +5,11 @@ namespace Hypervel\Permission\Middlewares; use BackedEnum; -use Hyperf\Collection\Collection; use Hyperf\Contract\ContainerInterface; use Hypervel\Auth\AuthManager; use Hypervel\Permission\Exceptions\RoleException; use Hypervel\Permission\Exceptions\UnauthorizedException; +use Hypervel\Support\Collection; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; diff --git a/src/permission/src/Models/Permission.php b/src/permission/src/Models/Permission.php index dd06373e2..568dc7fcd 100644 --- a/src/permission/src/Models/Permission.php +++ b/src/permission/src/Models/Permission.php @@ -5,9 +5,9 @@ namespace Hypervel\Permission\Models; use Carbon\Carbon; -use Hyperf\Database\Model\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Permission\Contracts\Permission as PermissionContract; use Hypervel\Permission\Traits\HasRole; diff --git a/src/permission/src/Models/Role.php b/src/permission/src/Models/Role.php index 4fafe71ed..9eb31028a 100644 --- a/src/permission/src/Models/Role.php +++ b/src/permission/src/Models/Role.php @@ -5,9 +5,9 @@ namespace Hypervel\Permission\Models; use Carbon\Carbon; -use Hyperf\Database\Model\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Permission\Contracts\Role as RoleContract; use Hypervel\Permission\Traits\HasPermission; diff --git a/src/permission/src/PermissionManager.php b/src/permission/src/PermissionManager.php index 20ecf1df5..e07b40e5f 100644 --- a/src/permission/src/PermissionManager.php +++ b/src/permission/src/PermissionManager.php @@ -5,8 +5,8 @@ namespace Hypervel\Permission; use Hyperf\Contract\ConfigInterface; -use Hypervel\Cache\Contracts\Factory as CacheManager; -use Hypervel\Cache\Contracts\Repository; +use Hypervel\Contracts\Cache\Factory as CacheManager; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Permission\Models\Permission; use Hypervel\Permission\Models\Role; use InvalidArgumentException; diff --git a/src/permission/src/Traits/HasPermission.php b/src/permission/src/Traits/HasPermission.php index 6b49cab13..fe18355d9 100644 --- a/src/permission/src/Traits/HasPermission.php +++ b/src/permission/src/Traits/HasPermission.php @@ -5,12 +5,12 @@ namespace Hypervel\Permission\Traits; use BackedEnum; -use Hyperf\Collection\Collection as BaseCollection; -use Hyperf\Database\Model\Relations\MorphToMany; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\Relations\MorphToMany; use Hypervel\Permission\Contracts\Permission; use Hypervel\Permission\Contracts\Role; use Hypervel\Permission\PermissionManager; +use Hypervel\Support\Collection as BaseCollection; use InvalidArgumentException; use UnitEnum; diff --git a/src/permission/src/Traits/HasRole.php b/src/permission/src/Traits/HasRole.php index e99b4d604..9a4cad4dd 100644 --- a/src/permission/src/Traits/HasRole.php +++ b/src/permission/src/Traits/HasRole.php @@ -5,12 +5,12 @@ namespace Hypervel\Permission\Traits; use BackedEnum; -use Hyperf\Collection\Collection as BaseCollection; -use Hyperf\Database\Model\Builder; -use Hyperf\Database\Model\Relations\MorphToMany; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\Relations\MorphToMany; use Hypervel\Permission\Contracts\Role; use Hypervel\Permission\PermissionManager; +use Hypervel\Support\Collection as BaseCollection; use InvalidArgumentException; use UnitEnum; diff --git a/src/pool/LICENSE.md b/src/pool/LICENSE.md new file mode 100644 index 000000000..63e1b7f54 --- /dev/null +++ b/src/pool/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Hyperf + +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. \ No newline at end of file diff --git a/src/pool/README.md b/src/pool/README.md new file mode 100644 index 000000000..12642c83d --- /dev/null +++ b/src/pool/README.md @@ -0,0 +1,4 @@ +Pool for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/pool) \ No newline at end of file diff --git a/src/pool/composer.json b/src/pool/composer.json new file mode 100644 index 000000000..3b04f543c --- /dev/null +++ b/src/pool/composer.json @@ -0,0 +1,43 @@ +{ + "name": "hypervel/pool", + "type": "library", + "description": "The Hypervel Pool package for connection pooling.", + "license": "MIT", + "keywords": [ + "php", + "pool", + "connection", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Pool\\": "src/" + } + }, + "require": { + "php": "^8.2", + "hyperf/contract": "~3.1.0", + "hyperf/coroutine": "~3.1.0", + "hyperf/engine": "^2.11", + "psr/container": "^1.0|^2.0" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.3-dev" + } + } +} diff --git a/src/pool/src/Channel.php b/src/pool/src/Channel.php new file mode 100644 index 000000000..ce53094e8 --- /dev/null +++ b/src/pool/src/Channel.php @@ -0,0 +1,76 @@ +channel = new CoChannel($size); + $this->queue = new SplQueue(); + } + + /** + * Pop a connection from the channel. + */ + public function pop(float $timeout): ConnectionInterface|false + { + if ($this->isCoroutine()) { + return $this->channel->pop($timeout); + } + + return $this->queue->shift(); + } + + /** + * Push a connection onto the channel. + */ + public function push(ConnectionInterface $data): bool + { + if ($this->isCoroutine()) { + return $this->channel->push($data); + } + + $this->queue->push($data); + + return true; + } + + /** + * Get the number of connections in the channel. + */ + public function length(): int + { + if ($this->isCoroutine()) { + return $this->channel->getLength(); + } + + return $this->queue->count(); + } + + /** + * Check if currently running in a coroutine. + */ + protected function isCoroutine(): bool + { + return Coroutine::id() > 0; + } +} diff --git a/src/pool/src/Connection.php b/src/pool/src/Connection.php new file mode 100644 index 000000000..f224f12bd --- /dev/null +++ b/src/pool/src/Connection.php @@ -0,0 +1,114 @@ +container->has(EventDispatcherInterface::class)) { + $this->dispatcher = $this->container->get(EventDispatcherInterface::class); + } + + if ($this->container->has(StdoutLoggerInterface::class)) { + $this->logger = $this->container->get(StdoutLoggerInterface::class); + } + } + + /** + * Release the connection back to the pool. + */ + public function release(): void + { + try { + $this->lastReleaseTime = microtime(true); + $events = $this->pool->getOption()->getEvents(); + + if (in_array(ReleaseConnection::class, $events, true)) { + $this->dispatcher?->dispatch(new ReleaseConnection($this)); + } + } catch (Throwable $exception) { + $this->logger?->error((string) $exception); + } finally { + $this->pool->release($this); + } + } + + /** + * Get the underlying connection, with retry on failure. + */ + public function getConnection(): mixed + { + try { + return $this->getActiveConnection(); + } catch (Throwable $exception) { + $this->logger?->warning('Get connection failed, try again. ' . $exception); + + return $this->getActiveConnection(); + } + } + + /** + * Check if the connection is still valid based on idle time. + */ + public function check(): bool + { + $maxIdleTime = $this->pool->getOption()->getMaxIdleTime(); + $now = microtime(true); + + if ($now > $maxIdleTime + $this->lastUseTime) { + return false; + } + + $this->lastUseTime = $now; + + return true; + } + + /** + * Get the last use time. + */ + public function getLastUseTime(): float + { + return $this->lastUseTime; + } + + /** + * Get the last release time. + */ + public function getLastReleaseTime(): float + { + return $this->lastReleaseTime; + } + + /** + * Get the active connection, reconnecting if necessary. + */ + abstract public function getActiveConnection(): mixed; +} diff --git a/src/pool/src/ConstantFrequency.php b/src/pool/src/ConstantFrequency.php new file mode 100644 index 000000000..53b78d691 --- /dev/null +++ b/src/pool/src/ConstantFrequency.php @@ -0,0 +1,63 @@ +timer = new Timer(); + + if ($pool) { + $this->timerId = $this->timer->tick( + $this->interval / 1000, + fn () => $this->pool->flushOne() + ); + } + } + + public function __destruct() + { + $this->clear(); + } + + /** + * Clear the timer. + */ + public function clear(): void + { + if ($this->timerId) { + $this->timer->clear($this->timerId); + } + + $this->timerId = null; + } + + /** + * Always returns false since flushing is handled by the timer. + */ + public function isLowFrequency(): bool + { + return false; + } +} diff --git a/src/pool/src/Context.php b/src/pool/src/Context.php new file mode 100644 index 000000000..abe6b1f08 --- /dev/null +++ b/src/pool/src/Context.php @@ -0,0 +1,46 @@ +logger = $container->get(StdoutLoggerInterface::class); + } + + /** + * Get a connection from request context. + */ + public function connection(): ?ConnectionInterface + { + if (CoroutineContext::has($this->name)) { + return CoroutineContext::get($this->name); + } + + return null; + } + + /** + * Set a connection in request context. + */ + public function set(ConnectionInterface $connection): void + { + CoroutineContext::set($this->name, $connection); + } +} diff --git a/src/pool/src/Event/ReleaseConnection.php b/src/pool/src/Event/ReleaseConnection.php new file mode 100644 index 000000000..8292c61de --- /dev/null +++ b/src/pool/src/Event/ReleaseConnection.php @@ -0,0 +1,18 @@ + + */ + protected array $hits = []; + + /** + * Time window in seconds for frequency calculation. + */ + protected int $time = 10; + + /** + * Threshold below which frequency is considered "low". + */ + protected int $lowFrequency = 5; + + /** + * Time when frequency tracking began. + */ + protected int $beginTime; + + /** + * Last time low frequency was triggered. + */ + protected int $lowFrequencyTime; + + /** + * Minimum interval between low frequency triggers. + */ + protected int $lowFrequencyInterval = 60; + + public function __construct( + protected ?Pool $pool = null + ) { + $this->beginTime = time(); + $this->lowFrequencyTime = time(); + } + + /** + * Record a hit. + */ + public function hit(int $number = 1): bool + { + $this->flush(); + + $now = time(); + $hit = $this->hits[$now] ?? 0; + $this->hits[$now] = $number + $hit; + + return true; + } + + /** + * Calculate the average frequency over the time window. + */ + public function frequency(): float + { + $this->flush(); + + $hits = 0; + $count = 0; + + foreach ($this->hits as $hit) { + ++$count; + $hits += $hit; + } + + return floatval($hits / $count); + } + + /** + * Check if currently in low frequency mode. + */ + public function isLowFrequency(): bool + { + $now = time(); + + if ($this->lowFrequencyTime + $this->lowFrequencyInterval < $now && $this->frequency() < $this->lowFrequency) { + $this->lowFrequencyTime = $now; + + return true; + } + + return false; + } + + /** + * Flush old hits outside the time window. + */ + protected function flush(): void + { + $now = time(); + $latest = $now - $this->time; + + foreach ($this->hits as $time => $hit) { + if ($time < $latest) { + unset($this->hits[$time]); + } + } + + if (count($this->hits) < $this->time) { + $beginTime = max($this->beginTime, $latest); + for ($i = $beginTime; $i < $now; ++$i) { + $this->hits[$i] = $this->hits[$i] ?? 0; + } + } + } +} diff --git a/src/pool/src/KeepaliveConnection.php b/src/pool/src/KeepaliveConnection.php new file mode 100644 index 000000000..3d2fbe54f --- /dev/null +++ b/src/pool/src/KeepaliveConnection.php @@ -0,0 +1,250 @@ +timer = new Timer(); + } + + public function __destruct() + { + $this->clear(); + } + + /** + * Release the connection back to the pool. + */ + public function release(): void + { + $this->pool->release($this); + } + + /** + * @throws InvalidArgumentException + */ + public function getConnection(): mixed + { + throw new InvalidArgumentException('Please use call instead of getConnection.'); + } + + /** + * Check if the connection is valid. + */ + public function check(): bool + { + return $this->isConnected(); + } + + /** + * Reconnect to the server. + */ + public function reconnect(): bool + { + $this->close(); + + $connection = $this->getActiveConnection(); + + $channel = new Channel(1); + $channel->push($connection); + $this->channel = $channel; + $this->lastUseTime = microtime(true); + + $this->addHeartbeat(); + + return true; + } + + /** + * Execute a closure with the connection. + * + * @param bool $refresh Whether to refresh the last use time + */ + public function call(Closure $closure, bool $refresh = true): mixed + { + if (! $this->isConnected()) { + $this->reconnect(); + } + + $connection = $this->channel->pop($this->pool->getOption()->getWaitTimeout()); + if ($connection === false) { + throw new SocketPopException(sprintf('Socket of %s is exhausted. Cannot establish socket before timeout.', $this->name)); + } + + try { + $result = $closure($connection); + if ($refresh) { + $this->lastUseTime = microtime(true); + } + } finally { + if ($this->isConnected()) { + $this->channel->push($connection, 0.001); + } else { + // Unset and drop the connection. + unset($connection); + } + } + + return $result; + } + + /** + * Check if currently connected. + */ + public function isConnected(): bool + { + return $this->connected; + } + + /** + * Close the connection. + */ + public function close(): bool + { + if ($this->isConnected()) { + $this->call(function ($connection) { + try { + if ($this->isConnected()) { + $this->sendClose($connection); + } + } finally { + $this->clear(); + } + }, false); + } + + return true; + } + + /** + * Check if the connection has timed out. + */ + public function isTimeout(): bool + { + return $this->lastUseTime < microtime(true) - $this->pool->getOption()->getMaxIdleTime() + && $this->channel->getLength() > 0; + } + + /** + * Add a heartbeat timer. + */ + protected function addHeartbeat(): void + { + $this->connected = true; + $this->timerId = $this->timer->tick($this->getHeartbeatSeconds(), function () { + try { + if (! $this->isConnected()) { + return; + } + + if ($this->isTimeout()) { + // The socket does not use in double of heartbeat. + $this->close(); + + return; + } + + $this->heartbeat(); + } catch (Throwable $throwable) { + $this->clear(); + if ($logger = $this->getLogger()) { + $message = sprintf('Socket of %s heartbeat failed, %s', $this->name, $throwable); + $logger->error($message); + } + } + }); + } + + /** + * Get the heartbeat interval in seconds. + */ + protected function getHeartbeatSeconds(): int + { + $heartbeat = $this->pool->getOption()->getHeartbeat(); + + if ($heartbeat > 0) { + return intval($heartbeat); + } + + return 10; + } + + /** + * Clear the connection state. + */ + protected function clear(): void + { + $this->connected = false; + + if ($this->timerId) { + $this->timer->clear($this->timerId); + $this->timerId = null; + } + } + + /** + * Get the logger instance. + */ + protected function getLogger(): ?LoggerInterface + { + if ($this->container->has(StdoutLoggerInterface::class)) { + return $this->container->get(StdoutLoggerInterface::class); + } + + return null; + } + + /** + * Send a heartbeat to keep the connection alive. + */ + protected function heartbeat(): void + { + } + + /** + * Send a close protocol message. + */ + protected function sendClose(mixed $connection): void + { + } + + /** + * Connect and return the active connection. + */ + abstract protected function getActiveConnection(): mixed; +} diff --git a/src/pool/src/LowFrequencyInterface.php b/src/pool/src/LowFrequencyInterface.php new file mode 100644 index 000000000..c371d4049 --- /dev/null +++ b/src/pool/src/LowFrequencyInterface.php @@ -0,0 +1,18 @@ +initOption($config); + + $this->channel = new Channel($this->option->getMaxConnections()); + } + + /** + * Get a connection from the pool. + */ + public function get(): ConnectionInterface + { + $connection = $this->getConnection(); + + try { + if ($this->frequency instanceof FrequencyInterface) { + $this->frequency->hit(); + } + + if ($this->frequency instanceof LowFrequencyInterface) { + if ($this->frequency->isLowFrequency()) { + $this->flush(); + } + } + } catch (Throwable $exception) { + $this->getLogger()?->error((string) $exception); + } + + return $connection; + } + + /** + * Release a connection back to the pool. + */ + public function release(ConnectionInterface $connection): void + { + $this->channel->push($connection); + } + + /** + * Flush excess connections down to the minimum pool size. + */ + public function flush(): void + { + $num = $this->getConnectionsInChannel(); + + if ($num > 0) { + while ($this->currentConnections > $this->option->getMinConnections() && $conn = $this->channel->pop(0.001)) { + try { + $conn->close(); + } catch (Throwable $exception) { + $this->getLogger()?->error((string) $exception); + } finally { + --$this->currentConnections; + --$num; + } + + if ($num <= 0) { + // Ignore connections queued during flushing. + break; + } + } + } + } + + /** + * Flush a single connection from the pool. + */ + public function flushOne(bool $force = false): void + { + $num = $this->getConnectionsInChannel(); + if ($num > 0 && $conn = $this->channel->pop(0.001)) { + if ($force || ! $conn->check()) { + try { + $conn->close(); + } catch (Throwable $exception) { + $this->getLogger()?->error((string) $exception); + } finally { + --$this->currentConnections; + } + } else { + $this->release($conn); + } + } + } + + /** + * Flush all connections from the pool. + */ + public function flushAll(): void + { + while ($this->getConnectionsInChannel() > 0) { + $this->flushOne(true); + } + } + + /** + * Get the current number of connections managed by the pool. + */ + public function getCurrentConnections(): int + { + return $this->currentConnections; + } + + /** + * Get the pool configuration options. + */ + public function getOption(): PoolOptionInterface + { + return $this->option; + } + + /** + * Get the number of connections currently available in the pool. + */ + public function getConnectionsInChannel(): int + { + return $this->channel->length(); + } + + /** + * Initialize pool options from configuration. + */ + protected function initOption(array $options = []): void + { + $this->option = new PoolOption( + minConnections: $options['min_connections'] ?? 1, + maxConnections: $options['max_connections'] ?? 10, + connectTimeout: $options['connect_timeout'] ?? 10.0, + waitTimeout: $options['wait_timeout'] ?? 3.0, + heartbeat: $options['heartbeat'] ?? -1, + maxIdleTime: $options['max_idle_time'] ?? 60.0, + events: $options['events'] ?? [], + ); + } + + /** + * Create a new connection for the pool. + */ + abstract protected function createConnection(): ConnectionInterface; + + /** + * Get a connection from the pool or create a new one. + */ + private function getConnection(): ConnectionInterface + { + $num = $this->getConnectionsInChannel(); + + try { + if ($num === 0 && $this->currentConnections < $this->option->getMaxConnections()) { + ++$this->currentConnections; + return $this->createConnection(); + } + } catch (Throwable $throwable) { + --$this->currentConnections; + throw $throwable; + } + + $connection = $this->channel->pop($this->option->getWaitTimeout()); + if (! $connection instanceof ConnectionInterface) { + throw new RuntimeException('Connection pool exhausted. Cannot establish new connection before wait_timeout.'); + } + + return $connection; + } + + /** + * Get the logger instance if available. + */ + private function getLogger(): ?StdoutLoggerInterface + { + if (! $this->container->has(StdoutLoggerInterface::class)) { + return null; + } + + return $this->container->get(StdoutLoggerInterface::class); + } +} diff --git a/src/pool/src/PoolOption.php b/src/pool/src/PoolOption.php new file mode 100644 index 000000000..92a36b348 --- /dev/null +++ b/src/pool/src/PoolOption.php @@ -0,0 +1,117 @@ + $events Events to trigger on connection lifecycle + */ + public function __construct( + private int $minConnections = 1, + private int $maxConnections = 10, + private float $connectTimeout = 10.0, + private float $waitTimeout = 3.0, + private float $heartbeat = -1, + private float $maxIdleTime = 60.0, + private array $events = [], + ) { + } + + public function getMaxConnections(): int + { + return $this->maxConnections; + } + + public function setMaxConnections(int $maxConnections): static + { + $this->maxConnections = $maxConnections; + + return $this; + } + + public function getMinConnections(): int + { + return $this->minConnections; + } + + public function setMinConnections(int $minConnections): static + { + $this->minConnections = $minConnections; + + return $this; + } + + public function getConnectTimeout(): float + { + return $this->connectTimeout; + } + + public function setConnectTimeout(float $connectTimeout): static + { + $this->connectTimeout = $connectTimeout; + + return $this; + } + + public function getHeartbeat(): float + { + return $this->heartbeat; + } + + public function setHeartbeat(float $heartbeat): static + { + $this->heartbeat = $heartbeat; + + return $this; + } + + public function getWaitTimeout(): float + { + return $this->waitTimeout; + } + + public function setWaitTimeout(float $waitTimeout): static + { + $this->waitTimeout = $waitTimeout; + + return $this; + } + + public function getMaxIdleTime(): float + { + return $this->maxIdleTime; + } + + public function setMaxIdleTime(float $maxIdleTime): static + { + $this->maxIdleTime = $maxIdleTime; + + return $this; + } + + public function getEvents(): array + { + return $this->events; + } + + public function setEvents(array $events): static + { + $this->events = $events; + + return $this; + } +} diff --git a/src/pool/src/SimplePool/Config.php b/src/pool/src/SimplePool/Config.php new file mode 100644 index 000000000..40327cf5e --- /dev/null +++ b/src/pool/src/SimplePool/Config.php @@ -0,0 +1,69 @@ + $option + */ + public function __construct( + protected string $name, + callable $callback, + protected array $option + ) { + $this->callback = $callback; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getCallback(): callable + { + return $this->callback; + } + + public function setCallback(callable $callback): static + { + $this->callback = $callback; + + return $this; + } + + /** + * @return array + */ + public function getOption(): array + { + return $this->option; + } + + /** + * @param array $option + */ + public function setOption(array $option): static + { + $this->option = $option; + + return $this; + } +} diff --git a/src/pool/src/SimplePool/Connection.php b/src/pool/src/SimplePool/Connection.php new file mode 100644 index 000000000..dafa3ee63 --- /dev/null +++ b/src/pool/src/SimplePool/Connection.php @@ -0,0 +1,55 @@ +callback = $callback; + + parent::__construct($container, $pool); + } + + public function getActiveConnection(): mixed + { + if (! $this->connection || ! $this->check()) { + $this->reconnect(); + } + + return $this->connection; + } + + public function reconnect(): bool + { + $this->connection = ($this->callback)(); + $this->lastUseTime = microtime(true); + + return true; + } + + public function close(): bool + { + $this->connection = null; + + return true; + } +} diff --git a/src/pool/src/SimplePool/Pool.php b/src/pool/src/SimplePool/Pool.php new file mode 100644 index 000000000..407ed4e0d --- /dev/null +++ b/src/pool/src/SimplePool/Pool.php @@ -0,0 +1,38 @@ + $option + */ + public function __construct( + ContainerInterface $container, + callable $callback, + array $option + ) { + $this->callback = $callback; + + parent::__construct($container, $option); + } + + protected function createConnection(): ConnectionInterface + { + return new Connection($this->container, $this, $this->callback); + } +} diff --git a/src/pool/src/SimplePool/PoolFactory.php b/src/pool/src/SimplePool/PoolFactory.php new file mode 100644 index 000000000..9eb556ad7 --- /dev/null +++ b/src/pool/src/SimplePool/PoolFactory.php @@ -0,0 +1,76 @@ + + */ + protected array $pools = []; + + /** + * @var array + */ + protected array $configs = []; + + public function __construct( + protected ContainerInterface $container + ) { + } + + public function addConfig(Config $config): static + { + $this->configs[$config->getName()] = $config; + + return $this; + } + + /** + * @param array $option + */ + public function get(string $name, callable $callback, array $option = []): Pool + { + if (! $this->hasConfig($name)) { + $config = new Config($name, $callback, $option); + $this->addConfig($config); + } + + $config = $this->getConfig($name); + + if (! isset($this->pools[$name])) { + $this->pools[$name] = new Pool( + $this->container, + $config->getCallback(), + $config->getOption() + ); + } + + return $this->pools[$name]; + } + + /** + * @return string[] + */ + public function getPoolNames(): array + { + return array_keys($this->pools); + } + + protected function hasConfig(string $name): bool + { + return isset($this->configs[$name]); + } + + protected function getConfig(string $name): Config + { + return $this->configs[$name]; + } +} diff --git a/src/process/composer.json b/src/process/composer.json index 16cb44c10..9c622f257 100644 --- a/src/process/composer.json +++ b/src/process/composer.json @@ -22,9 +22,8 @@ "require": { "php": "^8.2", "hypervel/support": "^0.3", - "hyperf/collection": "^3.1", - "hyperf/macroable": "^3.1", - "hyperf/tappable": "^3.1", + "hypervel/collections": "~0.3.0", + "hypervel/macroable": "~0.3.0", "symfony/process": "^7.0" }, "autoload": { diff --git a/src/process/src/Factory.php b/src/process/src/Factory.php index ab39e425c..deed5a63d 100644 --- a/src/process/src/Factory.php +++ b/src/process/src/Factory.php @@ -5,9 +5,9 @@ namespace Hypervel\Process; use Closure; -use Hyperf\Collection\Collection; -use Hyperf\Macroable\Macroable; use Hypervel\Process\Contracts\ProcessResult as ProcessResultContract; +use Hypervel\Support\Collection; +use Hypervel\Support\Traits\Macroable; use PHPUnit\Framework\Assert as PHPUnit; class Factory diff --git a/src/process/src/FakeProcessDescription.php b/src/process/src/FakeProcessDescription.php index dbba13d84..c5ceb86c3 100644 --- a/src/process/src/FakeProcessDescription.php +++ b/src/process/src/FakeProcessDescription.php @@ -4,8 +4,8 @@ namespace Hypervel\Process; -use Hyperf\Collection\Collection; use Hypervel\Process\Contracts\ProcessResult; +use Hypervel\Support\Collection; use Symfony\Component\Process\Process; class FakeProcessDescription diff --git a/src/process/src/FakeProcessResult.php b/src/process/src/FakeProcessResult.php index a56a7fb4b..5bf51b80a 100644 --- a/src/process/src/FakeProcessResult.php +++ b/src/process/src/FakeProcessResult.php @@ -4,9 +4,9 @@ namespace Hypervel\Process; -use Hyperf\Collection\Collection; use Hypervel\Process\Contracts\ProcessResult as ProcessResultContract; use Hypervel\Process\Exceptions\ProcessFailedException; +use Hypervel\Support\Collection; class FakeProcessResult implements ProcessResultContract { diff --git a/src/process/src/InvokedProcessPool.php b/src/process/src/InvokedProcessPool.php index 1321e6059..7534efce8 100644 --- a/src/process/src/InvokedProcessPool.php +++ b/src/process/src/InvokedProcessPool.php @@ -5,8 +5,8 @@ namespace Hypervel\Process; use Countable; -use Hyperf\Collection\Collection; use Hypervel\Process\Contracts\InvokedProcess; +use Hypervel\Support\Collection; class InvokedProcessPool implements Countable { diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index 10dce8425..4d2973bd9 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -5,11 +5,11 @@ namespace Hypervel\Process; use Closure; -use Hyperf\Collection\Collection; use Hyperf\Conditionable\Conditionable; use Hypervel\Process\Contracts\InvokedProcess as InvokedProcessContract; use Hypervel\Process\Contracts\ProcessResult as ProcessResultContract; use Hypervel\Process\Exceptions\ProcessTimedOutException; +use Hypervel\Support\Collection; use Hypervel\Support\Str; use LogicException; use RuntimeException; @@ -18,8 +18,6 @@ use Throwable; use Traversable; -use function Hyperf\Tappable\tap; - class PendingProcess { use Conditionable; diff --git a/src/process/src/Pipe.php b/src/process/src/Pipe.php index acf7fa43c..ddd32cda6 100644 --- a/src/process/src/Pipe.php +++ b/src/process/src/Pipe.php @@ -5,12 +5,10 @@ namespace Hypervel\Process; use Closure; -use Hyperf\Collection\Collection; use Hypervel\Process\Contracts\ProcessResult as ProcessResultContract; +use Hypervel\Support\Collection; use InvalidArgumentException; -use function Hyperf\Tappable\tap; - /** * @mixin \Hypervel\Process\Factory * @mixin \Hypervel\Process\PendingProcess diff --git a/src/process/src/Pool.php b/src/process/src/Pool.php index 5eed4e96d..8f9f9f23a 100644 --- a/src/process/src/Pool.php +++ b/src/process/src/Pool.php @@ -5,11 +5,9 @@ namespace Hypervel\Process; use Closure; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; use InvalidArgumentException; -use function Hyperf\Tappable\tap; - /** * @mixin \Hypervel\Process\Factory * @mixin \Hypervel\Process\PendingProcess diff --git a/src/process/src/ProcessPoolResults.php b/src/process/src/ProcessPoolResults.php index 1ebb9ae3c..dcdeb5a9f 100644 --- a/src/process/src/ProcessPoolResults.php +++ b/src/process/src/ProcessPoolResults.php @@ -5,7 +5,7 @@ namespace Hypervel\Process; use ArrayAccess; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; class ProcessPoolResults implements ArrayAccess { diff --git a/src/prompts/composer.json b/src/prompts/composer.json index abe63b1d8..d81e542c6 100644 --- a/src/prompts/composer.json +++ b/src/prompts/composer.json @@ -26,7 +26,7 @@ "nunomaduro/termwind": "^2.0" }, "require-dev": { - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", "mockery/mockery": "^1.5" }, "extra": { diff --git a/src/prompts/src/FormBuilder.php b/src/prompts/src/FormBuilder.php index acd075301..15f47f897 100644 --- a/src/prompts/src/FormBuilder.php +++ b/src/prompts/src/FormBuilder.php @@ -5,8 +5,8 @@ namespace Hypervel\Prompts; use Closure; -use Hyperf\Collection\Collection; use Hypervel\Prompts\Exceptions\FormRevertedException; +use Hypervel\Support\Collection; class FormBuilder { diff --git a/src/prompts/src/MultiSelectPrompt.php b/src/prompts/src/MultiSelectPrompt.php index 5dd5702ad..b850b7508 100644 --- a/src/prompts/src/MultiSelectPrompt.php +++ b/src/prompts/src/MultiSelectPrompt.php @@ -5,7 +5,7 @@ namespace Hypervel\Prompts; use Closure; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; class MultiSelectPrompt extends Prompt { diff --git a/src/prompts/src/SelectPrompt.php b/src/prompts/src/SelectPrompt.php index 5622ebb13..c50fc6e00 100644 --- a/src/prompts/src/SelectPrompt.php +++ b/src/prompts/src/SelectPrompt.php @@ -5,7 +5,7 @@ namespace Hypervel\Prompts; use Closure; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; use InvalidArgumentException; class SelectPrompt extends Prompt diff --git a/src/prompts/src/SuggestPrompt.php b/src/prompts/src/SuggestPrompt.php index 6d876541e..161cdfdd4 100644 --- a/src/prompts/src/SuggestPrompt.php +++ b/src/prompts/src/SuggestPrompt.php @@ -5,7 +5,7 @@ namespace Hypervel\Prompts; use Closure; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; class SuggestPrompt extends Prompt { diff --git a/src/prompts/src/Table.php b/src/prompts/src/Table.php index 12d1d4651..0e21fd857 100644 --- a/src/prompts/src/Table.php +++ b/src/prompts/src/Table.php @@ -4,7 +4,7 @@ namespace Hypervel\Prompts; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; class Table extends Prompt { diff --git a/src/prompts/src/Themes/Default/Concerns/DrawsScrollbars.php b/src/prompts/src/Themes/Default/Concerns/DrawsScrollbars.php index 77f2392c2..ca4bd6b0b 100644 --- a/src/prompts/src/Themes/Default/Concerns/DrawsScrollbars.php +++ b/src/prompts/src/Themes/Default/Concerns/DrawsScrollbars.php @@ -4,14 +4,14 @@ namespace Hypervel\Prompts\Themes\Default\Concerns; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; trait DrawsScrollbars { /** * Render a scrollbar beside the visible items. * - * @template T of array|\Hyperf\Collection\Collection + * @template T of array|\Hypervel\Support\Collection * * @param T $visible * @return T diff --git a/src/prompts/src/helpers.php b/src/prompts/src/helpers.php index 346871ab3..108103ea3 100644 --- a/src/prompts/src/helpers.php +++ b/src/prompts/src/helpers.php @@ -5,7 +5,7 @@ namespace Hypervel\Prompts; use Closure; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; if (! function_exists('\Hypervel\Prompts\text')) { /** diff --git a/src/queue/composer.json b/src/queue/composer.json index 887aac18f..78d9d2862 100644 --- a/src/queue/composer.json +++ b/src/queue/composer.json @@ -25,9 +25,8 @@ "hyperf/coordinator": "~3.1.0", "hyperf/contract": "~3.1.0", "hyperf/support": "~3.1.0", - "hyperf/collection": "~3.1.0", - "hyperf/tappable": "~3.1.0", - "hyperf/db-connection": "~3.1.0", + "hypervel/collections": "~0.3.0", + "hypervel/database": "^0.3", "laravel/serializable-closure": "^1.2.2", "ramsey/uuid": "^4.7", "symfony/process": "^7.0", diff --git a/src/queue/src/BeanstalkdQueue.php b/src/queue/src/BeanstalkdQueue.php index a00b21cc5..d6ff23b7d 100644 --- a/src/queue/src/BeanstalkdQueue.php +++ b/src/queue/src/BeanstalkdQueue.php @@ -6,8 +6,8 @@ use DateInterval; use DateTimeInterface; -use Hypervel\Queue\Contracts\Job as JobContract; -use Hypervel\Queue\Contracts\Queue as QueueContract; +use Hypervel\Contracts\Queue\Job as JobContract; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Queue\Jobs\BeanstalkdJob; use Pheanstalk\Contract\JobIdInterface; use Pheanstalk\Contract\PheanstalkManagerInterface; diff --git a/src/queue/src/CallQueuedClosure.php b/src/queue/src/CallQueuedClosure.php index 8e9ecfdbf..bd25ad544 100644 --- a/src/queue/src/CallQueuedClosure.php +++ b/src/queue/src/CallQueuedClosure.php @@ -8,7 +8,7 @@ use Hypervel\Bus\Batchable; use Hypervel\Bus\Dispatchable; use Hypervel\Bus\Queueable; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; use Laravel\SerializableClosure\SerializableClosure; use Psr\Container\ContainerInterface; use ReflectionFunction; @@ -58,7 +58,7 @@ public function handle(ContainerInterface $container): void return; } - /** @var \Hypervel\Container\Contracts\Container $container */ + /** @var \Hypervel\Contracts\Container\Container $container */ $container->call($this->closure->getClosure(), ['job' => $this]); } diff --git a/src/queue/src/CallQueuedHandler.php b/src/queue/src/CallQueuedHandler.php index 6eb3d2df2..388c00898 100644 --- a/src/queue/src/CallQueuedHandler.php +++ b/src/queue/src/CallQueuedHandler.php @@ -6,16 +6,16 @@ use __PHP_Incomplete_Class; use Exception; -use Hyperf\Database\Model\ModelNotFoundException; use Hypervel\Bus\Batchable; -use Hypervel\Bus\Contracts\Dispatcher; use Hypervel\Bus\UniqueLock; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Encryption\Contracts\Encrypter; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Encryption\Encrypter; +use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Contracts\Queue\ShouldBeUniqueUntilProcessing; +use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Queue\Attributes\DeleteWhenMissingModels; -use Hypervel\Queue\Contracts\Job; -use Hypervel\Queue\Contracts\ShouldBeUnique; -use Hypervel\Queue\Contracts\ShouldBeUniqueUntilProcessing; use Hypervel\Support\Pipeline; use Psr\Container\ContainerInterface; use ReflectionClass; diff --git a/src/queue/src/ConfigProvider.php b/src/queue/src/ConfigProvider.php index 2c095e217..e24cafd32 100644 --- a/src/queue/src/ConfigProvider.php +++ b/src/queue/src/ConfigProvider.php @@ -4,6 +4,8 @@ namespace Hypervel\Queue; +use Hypervel\Contracts\Queue\Factory as FactoryContract; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\Console\ClearCommand; use Hypervel\Queue\Console\FlushFailedCommand; use Hypervel\Queue\Console\ForgetFailedCommand; @@ -16,8 +18,6 @@ use Hypervel\Queue\Console\RetryBatchCommand; use Hypervel\Queue\Console\RetryCommand; use Hypervel\Queue\Console\WorkCommand; -use Hypervel\Queue\Contracts\Factory as FactoryContract; -use Hypervel\Queue\Contracts\Queue; use Hypervel\Queue\Failed\FailedJobProviderFactory; use Hypervel\Queue\Failed\FailedJobProviderInterface; use Laravel\SerializableClosure\SerializableClosure; diff --git a/src/queue/src/Connectors/BeanstalkdConnector.php b/src/queue/src/Connectors/BeanstalkdConnector.php index deb3d530e..bb07dc92b 100644 --- a/src/queue/src/Connectors/BeanstalkdConnector.php +++ b/src/queue/src/Connectors/BeanstalkdConnector.php @@ -4,8 +4,8 @@ namespace Hypervel\Queue\Connectors; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\BeanstalkdQueue; -use Hypervel\Queue\Contracts\Queue; use Pheanstalk\Contract\SocketFactoryInterface; use Pheanstalk\Pheanstalk; use Pheanstalk\Values\Timeout; diff --git a/src/queue/src/Connectors/ConnectorInterface.php b/src/queue/src/Connectors/ConnectorInterface.php index 547413760..39de14b95 100644 --- a/src/queue/src/Connectors/ConnectorInterface.php +++ b/src/queue/src/Connectors/ConnectorInterface.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Connectors; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; interface ConnectorInterface { diff --git a/src/queue/src/Connectors/CoroutineConnector.php b/src/queue/src/Connectors/CoroutineConnector.php index 9fbebff0a..2c043d9ef 100644 --- a/src/queue/src/Connectors/CoroutineConnector.php +++ b/src/queue/src/Connectors/CoroutineConnector.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Connectors; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\CoroutineQueue; class CoroutineConnector implements ConnectorInterface diff --git a/src/queue/src/Connectors/DatabaseConnector.php b/src/queue/src/Connectors/DatabaseConnector.php index 715dae61c..02e70f4d7 100644 --- a/src/queue/src/Connectors/DatabaseConnector.php +++ b/src/queue/src/Connectors/DatabaseConnector.php @@ -4,8 +4,8 @@ namespace Hypervel\Queue\Connectors; -use Hyperf\Database\ConnectionResolverInterface; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Queue\DatabaseQueue; class DatabaseConnector implements ConnectorInterface diff --git a/src/queue/src/Connectors/DeferConnector.php b/src/queue/src/Connectors/DeferConnector.php index 1dcfbb8dd..c55c1daec 100644 --- a/src/queue/src/Connectors/DeferConnector.php +++ b/src/queue/src/Connectors/DeferConnector.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Connectors; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\DeferQueue; class DeferConnector implements ConnectorInterface diff --git a/src/queue/src/Connectors/FailoverConnector.php b/src/queue/src/Connectors/FailoverConnector.php index f3671ee46..fd268e3fa 100644 --- a/src/queue/src/Connectors/FailoverConnector.php +++ b/src/queue/src/Connectors/FailoverConnector.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Connectors; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\FailoverQueue; use Hypervel\Queue\QueueManager; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/src/queue/src/Connectors/NullConnector.php b/src/queue/src/Connectors/NullConnector.php index 2668fbbc0..7fd22b79d 100644 --- a/src/queue/src/Connectors/NullConnector.php +++ b/src/queue/src/Connectors/NullConnector.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Connectors; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\NullQueue; class NullConnector implements ConnectorInterface diff --git a/src/queue/src/Connectors/RedisConnector.php b/src/queue/src/Connectors/RedisConnector.php index 453c106c3..e543a1142 100644 --- a/src/queue/src/Connectors/RedisConnector.php +++ b/src/queue/src/Connectors/RedisConnector.php @@ -5,7 +5,7 @@ namespace Hypervel\Queue\Connectors; use Hyperf\Redis\RedisFactory; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\RedisQueue; class RedisConnector implements ConnectorInterface diff --git a/src/queue/src/Connectors/SqsConnector.php b/src/queue/src/Connectors/SqsConnector.php index 11acd14ee..a828ce961 100644 --- a/src/queue/src/Connectors/SqsConnector.php +++ b/src/queue/src/Connectors/SqsConnector.php @@ -5,9 +5,9 @@ namespace Hypervel\Queue\Connectors; use Aws\Sqs\SqsClient; -use Hyperf\Collection\Arr; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\SqsQueue; +use Hypervel\Support\Arr; class SqsConnector implements ConnectorInterface { diff --git a/src/queue/src/Connectors/SyncConnector.php b/src/queue/src/Connectors/SyncConnector.php index b320c4f68..16ad26467 100644 --- a/src/queue/src/Connectors/SyncConnector.php +++ b/src/queue/src/Connectors/SyncConnector.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Connectors; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\SyncQueue; class SyncConnector implements ConnectorInterface diff --git a/src/queue/src/Console/ClearCommand.php b/src/queue/src/Console/ClearCommand.php index 08f4d0d3b..ed7e5983e 100644 --- a/src/queue/src/Console/ClearCommand.php +++ b/src/queue/src/Console/ClearCommand.php @@ -6,10 +6,10 @@ use Hyperf\Command\Command; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; use Hypervel\Console\ConfirmableTrait; -use Hypervel\Queue\Contracts\ClearableQueue; -use Hypervel\Queue\Contracts\Factory as FactoryContract; +use Hypervel\Contracts\Queue\ClearableQueue; +use Hypervel\Contracts\Queue\Factory as FactoryContract; +use Hypervel\Support\Str; use Hypervel\Support\Traits\HasLaravelStyleCommand; use ReflectionClass; use Symfony\Component\Console\Input\InputArgument; diff --git a/src/queue/src/Console/ListFailedCommand.php b/src/queue/src/Console/ListFailedCommand.php index 279d6c38d..b3ff739c8 100644 --- a/src/queue/src/Console/ListFailedCommand.php +++ b/src/queue/src/Console/ListFailedCommand.php @@ -4,10 +4,10 @@ namespace Hypervel\Queue\Console; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\Command\Command; use Hypervel\Queue\Failed\FailedJobProviderInterface; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Hypervel\Support\Traits\HasLaravelStyleCommand; class ListFailedCommand extends Command diff --git a/src/queue/src/Console/ListenCommand.php b/src/queue/src/Console/ListenCommand.php index 2ab6343da..c8f32b27f 100644 --- a/src/queue/src/Console/ListenCommand.php +++ b/src/queue/src/Console/ListenCommand.php @@ -6,9 +6,9 @@ use Hyperf\Command\Command; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; use Hypervel\Queue\Listener; use Hypervel\Queue\ListenerOptions; +use Hypervel\Support\Str; use Hypervel\Support\Traits\HasLaravelStyleCommand; class ListenCommand extends Command diff --git a/src/queue/src/Console/MonitorCommand.php b/src/queue/src/Console/MonitorCommand.php index 7ca6f1cf3..d06ac74af 100644 --- a/src/queue/src/Console/MonitorCommand.php +++ b/src/queue/src/Console/MonitorCommand.php @@ -4,11 +4,11 @@ namespace Hypervel\Queue\Console; -use Hyperf\Collection\Collection; use Hyperf\Command\Command; use Hyperf\Contract\ConfigInterface; -use Hypervel\Queue\Contracts\Factory; +use Hypervel\Contracts\Queue\Factory; use Hypervel\Queue\Events\QueueBusy; +use Hypervel\Support\Collection; use Hypervel\Support\Traits\HasLaravelStyleCommand; use Psr\EventDispatcher\EventDispatcherInterface; @@ -90,7 +90,7 @@ protected function parseQueues($queues): Collection */ protected function displaySizes(Collection $queues): void { - $this->table($this->headers, $queues); + $this->table($this->headers, $queues->toArray()); } /** diff --git a/src/queue/src/Console/PruneBatchesCommand.php b/src/queue/src/Console/PruneBatchesCommand.php index d2bba5731..317f0a793 100644 --- a/src/queue/src/Console/PruneBatchesCommand.php +++ b/src/queue/src/Console/PruneBatchesCommand.php @@ -5,9 +5,9 @@ namespace Hypervel\Queue\Console; use Hyperf\Command\Command; -use Hypervel\Bus\Contracts\BatchRepository; -use Hypervel\Bus\Contracts\PrunableBatchRepository; use Hypervel\Bus\DatabaseBatchRepository; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Bus\PrunableBatchRepository; use Hypervel\Support\Carbon; use Hypervel\Support\Traits\HasLaravelStyleCommand; diff --git a/src/queue/src/Console/RestartCommand.php b/src/queue/src/Console/RestartCommand.php index 2c45f734a..50954fd66 100644 --- a/src/queue/src/Console/RestartCommand.php +++ b/src/queue/src/Console/RestartCommand.php @@ -5,9 +5,9 @@ namespace Hypervel\Queue\Console; use Hyperf\Command\Command; -use Hypervel\Cache\Contracts\Factory as CacheFactory; +use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Support\Traits\HasLaravelStyleCommand; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Support\InteractsWithTime; class RestartCommand extends Command { diff --git a/src/queue/src/Console/RetryBatchCommand.php b/src/queue/src/Console/RetryBatchCommand.php index a8487c161..8333a6326 100644 --- a/src/queue/src/Console/RetryBatchCommand.php +++ b/src/queue/src/Console/RetryBatchCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Queue\Console; use Hyperf\Command\Command; -use Hypervel\Bus\Contracts\BatchRepository; +use Hypervel\Contracts\Bus\BatchRepository; use Hypervel\Support\Traits\HasLaravelStyleCommand; class RetryBatchCommand extends Command diff --git a/src/queue/src/Console/RetryCommand.php b/src/queue/src/Console/RetryCommand.php index 305bfcf4b..e5770c0d9 100644 --- a/src/queue/src/Console/RetryCommand.php +++ b/src/queue/src/Console/RetryCommand.php @@ -6,13 +6,13 @@ use __PHP_Incomplete_Class; use DateTimeInterface; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\Command\Command; -use Hypervel\Encryption\Contracts\Encrypter; -use Hypervel\Queue\Contracts\Factory as QueueFactory; +use Hypervel\Contracts\Encryption\Encrypter; +use Hypervel\Contracts\Queue\Factory as QueueFactory; use Hypervel\Queue\Events\JobRetryRequested; use Hypervel\Queue\Failed\FailedJobProviderInterface; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Hypervel\Support\Traits\HasLaravelStyleCommand; use Psr\EventDispatcher\EventDispatcherInterface; use RuntimeException; diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index fef95365a..2e0ab669c 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -6,9 +6,8 @@ use Hyperf\Command\Command; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Queue\Job; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobProcessed; use Hypervel\Queue\Events\JobProcessing; @@ -17,8 +16,9 @@ use Hypervel\Queue\Worker; use Hypervel\Queue\WorkerOptions; use Hypervel\Support\Carbon; +use Hypervel\Support\Str; use Hypervel\Support\Traits\HasLaravelStyleCommand; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Support\InteractsWithTime; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Console\Terminal; diff --git a/src/queue/src/CoroutineQueue.php b/src/queue/src/CoroutineQueue.php index 963705c4d..5df046270 100644 --- a/src/queue/src/CoroutineQueue.php +++ b/src/queue/src/CoroutineQueue.php @@ -5,7 +5,7 @@ namespace Hypervel\Queue; use Hypervel\Coroutine\Coroutine; -use Hypervel\Database\TransactionManager; +use Hypervel\Database\DatabaseTransactionsManager; use Throwable; class CoroutineQueue extends SyncQueue @@ -24,9 +24,9 @@ public function push(object|string $job, mixed $data = '', ?string $queue = null { if ( $this->shouldDispatchAfterCommit($job) - && $this->container->has(TransactionManager::class) + && $this->container->has(DatabaseTransactionsManager::class) ) { - return $this->container->get(TransactionManager::class) + return $this->container->get(DatabaseTransactionsManager::class) ->addCallback( fn () => $this->executeJob($job, $data, $queue) ); diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index ad789568f..313b57bed 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -6,17 +6,17 @@ use DateInterval; use DateTimeInterface; -use Hyperf\Collection\Collection; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Builder; -use Hyperf\Stringable\Str; -use Hypervel\Queue\Contracts\ClearableQueue; -use Hypervel\Queue\Contracts\Job; -use Hypervel\Queue\Contracts\Queue as QueueContract; +use Hypervel\Contracts\Queue\ClearableQueue; +use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\Queue as QueueContract; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; use Hypervel\Queue\Jobs\DatabaseJob; use Hypervel\Queue\Jobs\DatabaseJobRecord; use Hypervel\Support\Carbon; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use PDO; use Throwable; diff --git a/src/queue/src/DeferQueue.php b/src/queue/src/DeferQueue.php index c3cdbc739..d28fcfb7b 100644 --- a/src/queue/src/DeferQueue.php +++ b/src/queue/src/DeferQueue.php @@ -5,7 +5,7 @@ namespace Hypervel\Queue; use Hyperf\Engine\Coroutine; -use Hypervel\Database\TransactionManager; +use Hypervel\Database\DatabaseTransactionsManager; use Throwable; class DeferQueue extends SyncQueue @@ -23,9 +23,9 @@ class DeferQueue extends SyncQueue public function push(object|string $job, mixed $data = '', ?string $queue = null): mixed { if ($this->shouldDispatchAfterCommit($job) - && $this->container->has(TransactionManager::class) + && $this->container->has(DatabaseTransactionsManager::class) ) { - return $this->container->get(TransactionManager::class) + return $this->container->get(DatabaseTransactionsManager::class) ->addCallback( fn () => $this->deferJob($job, $data, $queue) ); diff --git a/src/queue/src/Events/JobAttempted.php b/src/queue/src/Events/JobAttempted.php index 6c7e7e036..bae30a1e6 100644 --- a/src/queue/src/Events/JobAttempted.php +++ b/src/queue/src/Events/JobAttempted.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Events; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; class JobAttempted { diff --git a/src/queue/src/Events/JobExceptionOccurred.php b/src/queue/src/Events/JobExceptionOccurred.php index 7e8146c22..9686ecef0 100644 --- a/src/queue/src/Events/JobExceptionOccurred.php +++ b/src/queue/src/Events/JobExceptionOccurred.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Events; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; use Throwable; class JobExceptionOccurred diff --git a/src/queue/src/Events/JobFailed.php b/src/queue/src/Events/JobFailed.php index 64f67a3bb..bd5fb8d96 100644 --- a/src/queue/src/Events/JobFailed.php +++ b/src/queue/src/Events/JobFailed.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Events; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; use Throwable; class JobFailed diff --git a/src/queue/src/Events/JobPopped.php b/src/queue/src/Events/JobPopped.php index 4e1f1992d..9d66ec199 100644 --- a/src/queue/src/Events/JobPopped.php +++ b/src/queue/src/Events/JobPopped.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Events; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; class JobPopped { diff --git a/src/queue/src/Events/JobProcessed.php b/src/queue/src/Events/JobProcessed.php index 0370d6ff9..33590ca6e 100644 --- a/src/queue/src/Events/JobProcessed.php +++ b/src/queue/src/Events/JobProcessed.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Events; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; class JobProcessed { diff --git a/src/queue/src/Events/JobProcessing.php b/src/queue/src/Events/JobProcessing.php index aa79c587c..e9f4b3936 100644 --- a/src/queue/src/Events/JobProcessing.php +++ b/src/queue/src/Events/JobProcessing.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Events; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; class JobProcessing { diff --git a/src/queue/src/Events/JobReleasedAfterException.php b/src/queue/src/Events/JobReleasedAfterException.php index 5e6922321..139bb6432 100644 --- a/src/queue/src/Events/JobReleasedAfterException.php +++ b/src/queue/src/Events/JobReleasedAfterException.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Events; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; class JobReleasedAfterException { diff --git a/src/queue/src/Events/JobTimedOut.php b/src/queue/src/Events/JobTimedOut.php index adda4f4ad..1d1b6a27d 100644 --- a/src/queue/src/Events/JobTimedOut.php +++ b/src/queue/src/Events/JobTimedOut.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Events; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; class JobTimedOut { diff --git a/src/queue/src/Exceptions/MaxAttemptsExceededException.php b/src/queue/src/Exceptions/MaxAttemptsExceededException.php index 03bb86eb9..944bd48df 100644 --- a/src/queue/src/Exceptions/MaxAttemptsExceededException.php +++ b/src/queue/src/Exceptions/MaxAttemptsExceededException.php @@ -4,11 +4,9 @@ namespace Hypervel\Queue\Exceptions; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; use RuntimeException; -use function Hyperf\Tappable\tap; - class MaxAttemptsExceededException extends RuntimeException { /** diff --git a/src/queue/src/Exceptions/TimeoutExceededException.php b/src/queue/src/Exceptions/TimeoutExceededException.php index c90d9bd47..ce0300a2f 100644 --- a/src/queue/src/Exceptions/TimeoutExceededException.php +++ b/src/queue/src/Exceptions/TimeoutExceededException.php @@ -4,9 +4,7 @@ namespace Hypervel\Queue\Exceptions; -use Hypervel\Queue\Contracts\Job; - -use function Hyperf\Tappable\tap; +use Hypervel\Contracts\Queue\Job; class TimeoutExceededException extends MaxAttemptsExceededException { diff --git a/src/queue/src/Failed/DatabaseFailedJobProvider.php b/src/queue/src/Failed/DatabaseFailedJobProvider.php index 5ec464b6d..1f49e0e25 100644 --- a/src/queue/src/Failed/DatabaseFailedJobProvider.php +++ b/src/queue/src/Failed/DatabaseFailedJobProvider.php @@ -6,8 +6,8 @@ use Carbon\Carbon; use DateTimeInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Builder; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; use Throwable; class DatabaseFailedJobProvider implements CountableFailedJobProvider, FailedJobProviderInterface, PrunableFailedJobProvider @@ -110,8 +110,8 @@ public function prune(DateTimeInterface $before): int public function count(?string $connection = null, ?string $queue = null): int { return $this->getTable() - ->when($connection, fn ($builder) => $builder->whereConnection($connection)) - ->when($queue, fn ($builder) => $builder->whereQueue($queue)) + ->when($connection, fn ($builder) => $builder->where('connection', $connection)) + ->when($queue, fn ($builder) => $builder->where('queue', $queue)) ->count(); } diff --git a/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php b/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php index 1cd28d106..0d7a13eed 100644 --- a/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php +++ b/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php @@ -5,8 +5,8 @@ namespace Hypervel\Queue\Failed; use DateTimeInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Builder; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; use Hypervel\Support\Carbon; use Throwable; @@ -119,8 +119,8 @@ public function prune(DateTimeInterface $before): int public function count(?string $connection = null, ?string $queue = null): int { return $this->getTable() - ->when($connection, fn ($builder) => $builder->whereConnection($connection)) - ->when($queue, fn ($builder) => $builder->whereQueue($queue)) + ->when($connection, fn ($builder) => $builder->where('connection', $connection)) + ->when($queue, fn ($builder) => $builder->where('queue', $queue)) ->count(); } diff --git a/src/queue/src/Failed/FailedJobProviderFactory.php b/src/queue/src/Failed/FailedJobProviderFactory.php index c66880d30..bbb448682 100644 --- a/src/queue/src/Failed/FailedJobProviderFactory.php +++ b/src/queue/src/Failed/FailedJobProviderFactory.php @@ -5,8 +5,8 @@ namespace Hypervel\Queue\Failed; use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hypervel\Cache\Contracts\Factory as CacheFactoryContract; +use Hypervel\Contracts\Cache\Factory as CacheFactoryContract; +use Hypervel\Database\ConnectionResolverInterface; use Psr\Container\ContainerInterface; class FailedJobProviderFactory diff --git a/src/queue/src/Failed/FileFailedJobProvider.php b/src/queue/src/Failed/FileFailedJobProvider.php index aaa3b1b1f..0b4b76da3 100644 --- a/src/queue/src/Failed/FileFailedJobProvider.php +++ b/src/queue/src/Failed/FileFailedJobProvider.php @@ -6,8 +6,8 @@ use Closure; use DateTimeInterface; -use Hyperf\Collection\Collection; use Hypervel\Support\Carbon; +use Hypervel\Support\Collection; use Throwable; class FileFailedJobProvider implements CountableFailedJobProvider, FailedJobProviderInterface, PrunableFailedJobProvider diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 9deaee2f9..b6b177ce3 100644 --- a/src/queue/src/FailoverQueue.php +++ b/src/queue/src/FailoverQueue.php @@ -6,8 +6,8 @@ use DateInterval; use DateTimeInterface; -use Hypervel\Queue\Contracts\Job as JobContract; -use Hypervel\Queue\Contracts\Queue as QueueContract; +use Hypervel\Contracts\Queue\Job as JobContract; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Queue\Events\QueueFailedOver; use Psr\EventDispatcher\EventDispatcherInterface; use RuntimeException; diff --git a/src/queue/src/InteractsWithQueue.php b/src/queue/src/InteractsWithQueue.php index f34355939..ad690c2de 100644 --- a/src/queue/src/InteractsWithQueue.php +++ b/src/queue/src/InteractsWithQueue.php @@ -7,7 +7,7 @@ use DateInterval; use DateTimeInterface; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Queue\Contracts\Job as JobContract; +use Hypervel\Contracts\Queue\Job as JobContract; use Hypervel\Queue\Exceptions\ManuallyFailedException; use Hypervel\Queue\Jobs\FakeJob; use PHPUnit\Framework\Assert as PHPUnit; diff --git a/src/queue/src/Jobs/FakeJob.php b/src/queue/src/Jobs/FakeJob.php index 4808352e7..e1b4ee607 100644 --- a/src/queue/src/Jobs/FakeJob.php +++ b/src/queue/src/Jobs/FakeJob.php @@ -6,7 +6,7 @@ use DateInterval; use DateTimeInterface; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; use Throwable; class FakeJob extends Job diff --git a/src/queue/src/Jobs/Job.php b/src/queue/src/Jobs/Job.php index 9c843741c..87343fbf5 100644 --- a/src/queue/src/Jobs/Job.php +++ b/src/queue/src/Jobs/Job.php @@ -6,8 +6,8 @@ use Hyperf\Support\Traits\InteractsWithTime; use Hypervel\Bus\Batchable; -use Hypervel\Bus\Contracts\BatchRepository; -use Hypervel\Queue\Contracts\Job as JobContract; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Queue\Job as JobContract; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Exceptions\ManuallyFailedException; use Hypervel\Queue\Exceptions\TimeoutExceededException; diff --git a/src/queue/src/Jobs/JobName.php b/src/queue/src/Jobs/JobName.php index b184c45b1..a4a9e440c 100644 --- a/src/queue/src/Jobs/JobName.php +++ b/src/queue/src/Jobs/JobName.php @@ -4,8 +4,8 @@ namespace Hypervel\Queue\Jobs; -use Hyperf\Stringable\Str; use Hypervel\Queue\CallQueuedHandler; +use Hypervel\Support\Str; class JobName { diff --git a/src/queue/src/Middleware/RateLimited.php b/src/queue/src/Middleware/RateLimited.php index b5c2d1acc..9584a65de 100644 --- a/src/queue/src/Middleware/RateLimited.php +++ b/src/queue/src/Middleware/RateLimited.php @@ -4,11 +4,11 @@ namespace Hypervel\Queue\Middleware; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\Context\ApplicationContext; use Hypervel\Cache\RateLimiter; use Hypervel\Cache\RateLimiting\Unlimited; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use UnitEnum; use function Hypervel\Support\enum_value; diff --git a/src/queue/src/Middleware/RateLimitedWithRedis.php b/src/queue/src/Middleware/RateLimitedWithRedis.php index 89d3b1ccc..52afd9c92 100644 --- a/src/queue/src/Middleware/RateLimitedWithRedis.php +++ b/src/queue/src/Middleware/RateLimitedWithRedis.php @@ -8,9 +8,7 @@ use Hyperf\Contract\ConfigInterface; use Hyperf\Redis\RedisFactory; use Hypervel\Redis\Limiters\DurationLimiter; -use Hypervel\Support\Traits\InteractsWithTime; - -use function Hyperf\Tappable\tap; +use Hypervel\Support\InteractsWithTime; class RateLimitedWithRedis extends RateLimited { diff --git a/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php b/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php index dc3577ed7..b08e72d4a 100644 --- a/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php +++ b/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php @@ -8,7 +8,7 @@ use Hyperf\Contract\ConfigInterface; use Hyperf\Redis\RedisFactory; use Hypervel\Redis\Limiters\DurationLimiter; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Support\InteractsWithTime; use Throwable; class ThrottlesExceptionsWithRedis extends ThrottlesExceptions diff --git a/src/queue/src/Middleware/WithoutOverlapping.php b/src/queue/src/Middleware/WithoutOverlapping.php index 849958ad1..2ba0ab3d3 100644 --- a/src/queue/src/Middleware/WithoutOverlapping.php +++ b/src/queue/src/Middleware/WithoutOverlapping.php @@ -7,8 +7,8 @@ use DateInterval; use DateTimeInterface; use Hyperf\Context\ApplicationContext; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Support\InteractsWithTime; class WithoutOverlapping { diff --git a/src/queue/src/NullQueue.php b/src/queue/src/NullQueue.php index 45e088c7c..683e18c0a 100644 --- a/src/queue/src/NullQueue.php +++ b/src/queue/src/NullQueue.php @@ -6,8 +6,8 @@ use DateInterval; use DateTimeInterface; -use Hypervel\Queue\Contracts\Job; -use Hypervel\Queue\Contracts\Queue as QueueContract; +use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\Queue as QueueContract; class NullQueue extends Queue implements QueueContract { diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 365e4c033..7382fc64c 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -7,22 +7,20 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; -use Hyperf\Stringable\Str; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Database\TransactionManager; -use Hypervel\Encryption\Contracts\Encrypter; -use Hypervel\Queue\Contracts\ShouldBeEncrypted; -use Hypervel\Queue\Contracts\ShouldQueueAfterCommit; +use Hypervel\Contracts\Encryption\Encrypter; +use Hypervel\Contracts\Queue\ShouldBeEncrypted; +use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; use Hypervel\Queue\Exceptions\InvalidPayloadException; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; -use function Hyperf\Tappable\tap; - use const JSON_UNESCAPED_UNICODE; abstract class Queue @@ -279,9 +277,9 @@ protected function withCreatePayloadHooks(?string $queue, array $payload): array protected function enqueueUsing(object|string $job, ?string $payload, ?string $queue, DateInterval|DateTimeInterface|int|null $delay, callable $callback): mixed { if ($this->shouldDispatchAfterCommit($job) - && $this->container->has(TransactionManager::class) + && $this->container->has(DatabaseTransactionsManager::class) ) { - return $this->container->get(TransactionManager::class) + return $this->container->get(DatabaseTransactionsManager::class) ->addCallback( function () use ($queue, $job, $payload, $delay, $callback) { $this->raiseJobQueueingEvent($queue, $job, $payload, $delay); diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 780bc7358..d4bc9db38 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -6,8 +6,11 @@ use Closure; use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\ConnectionResolverInterface; use Hyperf\Redis\RedisFactory; +use Hypervel\Contracts\Queue\Factory as FactoryContract; +use Hypervel\Contracts\Queue\Monitor as MonitorContract; +use Hypervel\Contracts\Queue\Queue; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\ObjectPool\Traits\HasPoolProxy; use Hypervel\Queue\Connectors\BeanstalkdConnector; use Hypervel\Queue\Connectors\ConnectorInterface; @@ -19,15 +22,12 @@ use Hypervel\Queue\Connectors\RedisConnector; use Hypervel\Queue\Connectors\SqsConnector; use Hypervel\Queue\Connectors\SyncConnector; -use Hypervel\Queue\Contracts\Factory as FactoryContract; -use Hypervel\Queue\Contracts\Monitor as MonitorContract; -use Hypervel\Queue\Contracts\Queue; use InvalidArgumentException; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; /** - * @mixin \Hypervel\Queue\Contracts\Queue + * @mixin \Hypervel\Contracts\Queue\Queue */ class QueueManager implements FactoryContract, MonitorContract { diff --git a/src/queue/src/QueueManagerFactory.php b/src/queue/src/QueueManagerFactory.php index ec954db5e..2144bc207 100644 --- a/src/queue/src/QueueManagerFactory.php +++ b/src/queue/src/QueueManagerFactory.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; +use Hypervel\Contracts\Debug\ExceptionHandler; use InvalidArgumentException; use Psr\Container\ContainerInterface; use Throwable; diff --git a/src/queue/src/QueuePoolProxy.php b/src/queue/src/QueuePoolProxy.php index b688c571a..643e6fd1f 100644 --- a/src/queue/src/QueuePoolProxy.php +++ b/src/queue/src/QueuePoolProxy.php @@ -6,9 +6,9 @@ use DateInterval; use DateTimeInterface; +use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\Queue; use Hypervel\ObjectPool\PoolProxy; -use Hypervel\Queue\Contracts\Job; -use Hypervel\Queue\Contracts\Queue; class QueuePoolProxy extends PoolProxy implements Queue { diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index 99008019e..2a7e4fd64 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -8,11 +8,11 @@ use DateTimeInterface; use Hyperf\Redis\RedisFactory; use Hyperf\Redis\RedisProxy; -use Hyperf\Stringable\Str; -use Hypervel\Queue\Contracts\ClearableQueue; -use Hypervel\Queue\Contracts\Job as JobContract; -use Hypervel\Queue\Contracts\Queue as QueueContract; +use Hypervel\Contracts\Queue\ClearableQueue; +use Hypervel\Contracts\Queue\Job as JobContract; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Queue\Jobs\RedisJob; +use Hypervel\Support\Str; class RedisQueue extends Queue implements QueueContract, ClearableQueue { diff --git a/src/queue/src/SerializesAndRestoresModelIdentifiers.php b/src/queue/src/SerializesAndRestoresModelIdentifiers.php index b4f029e18..e0ec90b1e 100644 --- a/src/queue/src/SerializesAndRestoresModelIdentifiers.php +++ b/src/queue/src/SerializesAndRestoresModelIdentifiers.php @@ -4,15 +4,15 @@ namespace Hypervel\Queue; -use Hyperf\Collection\Collection; -use Hyperf\Database\Model\Builder; -use Hyperf\Database\Model\Collection as EloquentCollection; -use Hyperf\Database\Model\Model; -use Hyperf\Database\Model\Relations\Concerns\AsPivot; -use Hyperf\Database\Model\Relations\Pivot; +use Hypervel\Contracts\Queue\QueueableCollection; +use Hypervel\Contracts\Queue\QueueableEntity; +use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\Concerns\AsPivot; +use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Database\ModelIdentifier; -use Hypervel\Queue\Contracts\QueueableCollection; -use Hypervel\Queue\Contracts\QueueableEntity; +use Hypervel\Support\Collection; trait SerializesAndRestoresModelIdentifiers { diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index 45eb5059c..dc6b944a4 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -7,13 +7,11 @@ use Aws\Sqs\SqsClient; use DateInterval; use DateTimeInterface; -use Hyperf\Stringable\Str; -use Hypervel\Queue\Contracts\ClearableQueue; -use Hypervel\Queue\Contracts\Job as JobContract; -use Hypervel\Queue\Contracts\Queue as QueueContract; +use Hypervel\Contracts\Queue\ClearableQueue; +use Hypervel\Contracts\Queue\Job as JobContract; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Queue\Jobs\SqsJob; - -use function Hyperf\Tappable\tap; +use Hypervel\Support\Str; class SqsQueue extends Queue implements QueueContract, ClearableQueue { diff --git a/src/queue/src/SyncQueue.php b/src/queue/src/SyncQueue.php index 0821aeb04..833d1f9e2 100644 --- a/src/queue/src/SyncQueue.php +++ b/src/queue/src/SyncQueue.php @@ -6,10 +6,10 @@ use DateInterval; use DateTimeInterface; -use Hypervel\Database\TransactionManager; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; -use Hypervel\Queue\Contracts\Job as JobContract; -use Hypervel\Queue\Contracts\Queue as QueueContract; +use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Contracts\Queue\Job as JobContract; +use Hypervel\Contracts\Queue\Queue as QueueContract; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobProcessed; use Hypervel\Queue\Events\JobProcessing; @@ -75,9 +75,9 @@ public function creationTimeOfOldestPendingJob(?string $queue = null): ?int public function push(object|string $job, mixed $data = '', ?string $queue = null): mixed { if ($this->shouldDispatchAfterCommit($job) - && $this->container->has(TransactionManager::class) + && $this->container->has(DatabaseTransactionsManager::class) ) { - return $this->container->get(TransactionManager::class) + return $this->container->get(DatabaseTransactionsManager::class) ->addCallback( fn () => $this->executeJob($job, $data, $queue) ); diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 2d3f93f56..f066a16a3 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -6,14 +6,14 @@ use Hyperf\Coordinator\Timer; use Hyperf\Coroutine\Concurrent; -use Hyperf\Database\DetectsLostConnections; -use Hyperf\Stringable\Str; -use Hypervel\Cache\Contracts\Factory as CacheFactory; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Event\Dispatcher as EventDispatcher; +use Hypervel\Contracts\Queue\Factory as QueueManager; +use Hypervel\Contracts\Queue\Job as JobContract; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Coroutine\Waiter; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; -use Hypervel\Queue\Contracts\Factory as QueueManager; -use Hypervel\Queue\Contracts\Job as JobContract; -use Hypervel\Queue\Contracts\Queue as QueueContract; +use Hypervel\Database\DetectsLostConnections; use Hypervel\Queue\Events\JobAttempted; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobPopped; @@ -27,7 +27,7 @@ use Hypervel\Queue\Exceptions\MaxAttemptsExceededException; use Hypervel\Queue\Exceptions\TimeoutExceededException; use Hypervel\Support\Carbon; -use Psr\EventDispatcher\EventDispatcherInterface; +use Hypervel\Support\Str; use Throwable; class Worker @@ -112,14 +112,14 @@ class Worker * Create a new queue worker. * * @param QueueManager $manager the queue manager instance - * @param EventDispatcherInterface $events the event dispatcher instance + * @param EventDispatcher $events the event dispatcher instance * @param ExceptionHandlerContract $exceptions the exception handler instance * @param callable $isDownForMaintenance the callback used to determine if the application is in maintenance mode * @param int $monitorInterval the monitor interval */ public function __construct( protected QueueManager $manager, - protected EventDispatcherInterface $events, + protected EventDispatcher $events, protected ExceptionHandlerContract $exceptions, callable $isDownForMaintenance, ?callable $monitorTimeoutJobs = null, @@ -312,7 +312,7 @@ protected function daemonShouldRun(WorkerOptions $options, string $connectionNam { return ! ((($this->isDownForMaintenance)() && ! $options->force) || $this->paused - || ! tap($this->events->dispatch(new Looping($connectionName, $queue)), fn ($event) => $event->shouldRun())); + || $this->events->until(new Looping($connectionName, $queue)) === false); } /** diff --git a/src/queue/src/WorkerFactory.php b/src/queue/src/WorkerFactory.php index 2138e5214..d9e4f21dc 100644 --- a/src/queue/src/WorkerFactory.php +++ b/src/queue/src/WorkerFactory.php @@ -4,8 +4,8 @@ namespace Hypervel\Queue; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; -use Hypervel\Queue\Contracts\Factory as QueueManager; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Queue\Factory as QueueManager; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/src/redis/composer.json b/src/redis/composer.json index 1c3c2a34c..4617272cc 100644 --- a/src/redis/composer.json +++ b/src/redis/composer.json @@ -28,6 +28,7 @@ "require": { "php": "^8.2", "hyperf/redis": "~3.1.0", + "hypervel/pool": "~0.3", "hypervel/support": "^0.3" }, "config": { diff --git a/src/reflection/LICENSE.md b/src/reflection/LICENSE.md new file mode 100644 index 000000000..1fdd1ef99 --- /dev/null +++ b/src/reflection/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +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/reflection/composer.json b/src/reflection/composer.json new file mode 100644 index 000000000..b1d26d48a --- /dev/null +++ b/src/reflection/composer.json @@ -0,0 +1,41 @@ +{ + "name": "hypervel/reflection", + "type": "library", + "description": "The Hypervel Reflection package.", + "license": "MIT", + "keywords": [ + "php", + "reflection", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Support\\": "src/" + }, + "files": [ + "src/helpers.php" + ] + }, + "require": { + "php": "^8.2", + "hypervel/support": "self.version" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.3-dev" + } + } +} diff --git a/src/support/src/Traits/ReflectsClosures.php b/src/reflection/src/Traits/ReflectsClosures.php similarity index 62% rename from src/support/src/Traits/ReflectsClosures.php rename to src/reflection/src/Traits/ReflectsClosures.php index 2dbc07b53..df138f757 100644 --- a/src/support/src/Traits/ReflectsClosures.php +++ b/src/reflection/src/Traits/ReflectsClosures.php @@ -5,10 +5,13 @@ namespace Hypervel\Support\Traits; use Closure; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; use Hypervel\Support\Reflector; use ReflectionException; use ReflectionFunction; +use ReflectionIntersectionType; +use ReflectionNamedType; +use ReflectionUnionType; use RuntimeException; trait ReflectsClosures @@ -16,12 +19,10 @@ trait ReflectsClosures /** * Get the class name of the first parameter of the given Closure. * - * @return string - * * @throws ReflectionException * @throws RuntimeException */ - protected function firstClosureParameterType(Closure $closure) + protected function firstClosureParameterType(Closure $closure): string { $types = array_values($this->closureParameterTypes($closure)); @@ -39,12 +40,12 @@ protected function firstClosureParameterType(Closure $closure) /** * Get the class names of the first parameter of the given Closure, including union types. * - * @return array + * @return list * * @throws ReflectionException * @throws RuntimeException */ - protected function firstClosureParameterTypes(Closure $closure) + protected function firstClosureParameterTypes(Closure $closure): array { $reflection = new ReflectionFunction($closure); @@ -71,11 +72,9 @@ protected function firstClosureParameterTypes(Closure $closure) /** * Get the class names / types of the parameters of the given Closure. * - * @return array - * - * @throws ReflectionException + * @return array */ - protected function closureParameterTypes(Closure $closure) + protected function closureParameterTypes(Closure $closure): array { $reflection = new ReflectionFunction($closure); @@ -87,4 +86,34 @@ protected function closureParameterTypes(Closure $closure) return [$parameter->getName() => Reflector::getParameterClassName($parameter)]; })->all(); } + + /** + * Get the class names / types of the return type of the given Closure. + * + * @return list + */ + protected function closureReturnTypes(Closure $closure): array + { + $reflection = new ReflectionFunction($closure); + + if ($reflection->getReturnType() === null + || $reflection->getReturnType() instanceof ReflectionIntersectionType) { + return []; + } + + $types = $reflection->getReturnType() instanceof ReflectionUnionType + ? $reflection->getReturnType()->getTypes() + : [$reflection->getReturnType()]; + + /** @var Collection $namedTypes */ + $namedTypes = Collection::make($types) + ->filter(fn ($type) => $type instanceof ReflectionNamedType); + + return $namedTypes + ->reject(fn (ReflectionNamedType $type) => $type->isBuiltin()) + ->reject(fn (ReflectionNamedType $type) => in_array($type->getName(), ['static', 'self'])) + ->map(fn (ReflectionNamedType $type) => $type->getName()) + ->values() + ->all(); + } } diff --git a/src/reflection/src/helpers.php b/src/reflection/src/helpers.php new file mode 100644 index 000000000..578db27cb --- /dev/null +++ b/src/reflection/src/helpers.php @@ -0,0 +1,99 @@ +|(Closure(TValue): mixed) $class + * @param (Closure(TValue): mixed)|int $callback + * @param int $options + * @param array $eager + * @return TValue + */ + function lazy(string|Closure $class, Closure|int $callback = 0, int $options = 0, array $eager = []): object + { + static $closureReflector; + + $closureReflector ??= new class + { + use ReflectsClosures; + + public function typeFromParameter(Closure $callback): string + { + return $this->firstClosureParameterType($callback); + } + }; + + [$class, $callback, $options] = is_string($class) + ? [$class, $callback, $options] + : [$closureReflector->typeFromParameter($class), $class, $callback ?: $options]; + + $reflectionClass = new ReflectionClass($class); + + $instance = $reflectionClass->newLazyGhost(function ($instance) use ($callback) { + $result = $callback($instance); + + if (is_array($result)) { + $instance->__construct(...$result); + } + }, $options); + + foreach ($eager as $property => $value) { + $reflectionClass->getProperty($property)->setRawValueWithoutLazyInitialization($instance, $value); + } + + return $instance; + } +} + +if (! function_exists('proxy')) { + /** + * Create a lazy proxy instance. + * + * @template TValue of object + * + * @param class-string|(Closure(TValue): TValue) $class + * @param (Closure(TValue): TValue)|int $callback + * @param int $options + * @param array $eager + * @return TValue + */ + function proxy(string|Closure $class, Closure|int $callback = 0, int $options = 0, array $eager = []): object + { + static $closureReflector; + + $closureReflector ??= new class + { + use ReflectsClosures; + + public function get(Closure $callback): string + { + return $this->closureReturnTypes($callback)[0] ?? $this->firstClosureParameterType($callback); + } + }; + + [$class, $callback, $options] = is_string($class) + ? [$class, $callback, $options] + : [$closureReflector->get($class), $class, $callback ?: $options]; + + $reflectionClass = new ReflectionClass($class); + + $proxy = $reflectionClass->newLazyProxy(function () use ($callback, $eager, &$proxy) { + $instance = $callback($proxy, $eager); + + return $instance; + }, $options); + + foreach ($eager as $property => $value) { + $reflectionClass->getProperty($property)->setRawValueWithoutLazyInitialization($proxy, $value); + } + + return $proxy; + } +} diff --git a/src/router/src/ConfigProvider.php b/src/router/src/ConfigProvider.php index 06a176f54..59dff2db2 100644 --- a/src/router/src/ConfigProvider.php +++ b/src/router/src/ConfigProvider.php @@ -10,7 +10,7 @@ use FastRoute\RouteParser\Std as RouterParser; use Hyperf\HttpServer\Router\DispatcherFactory as HyperfDispatcherFactory; use Hyperf\HttpServer\Router\RouteCollector as HyperfRouteCollector; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; class ConfigProvider { diff --git a/src/router/src/Functions.php b/src/router/src/Functions.php index 89d8a0379..58141312b 100644 --- a/src/router/src/Functions.php +++ b/src/router/src/Functions.php @@ -5,7 +5,7 @@ namespace Hypervel\Router; use Hyperf\Context\ApplicationContext; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; use InvalidArgumentException; /** diff --git a/src/router/src/Middleware/SubstituteBindings.php b/src/router/src/Middleware/SubstituteBindings.php index 105774755..a5db2f311 100644 --- a/src/router/src/Middleware/SubstituteBindings.php +++ b/src/router/src/Middleware/SubstituteBindings.php @@ -6,12 +6,12 @@ use BackedEnum; use Closure; -use Hyperf\Database\Model\Model; -use Hyperf\Database\Model\ModelNotFoundException; use Hyperf\Di\ReflectionType; use Hyperf\HttpServer\Router\Dispatched; +use Hypervel\Contracts\Router\UrlRoutable; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Http\RouteDependency; -use Hypervel\Router\Contracts\UrlRoutable; use Hypervel\Router\Exceptions\BackedEnumCaseNotFoundException; use Hypervel\Router\Exceptions\UrlRoutableNotFoundException; use Hypervel\Router\Router; diff --git a/src/router/src/Middleware/ThrottleRequests.php b/src/router/src/Middleware/ThrottleRequests.php index c8900a8a9..e547c4679 100644 --- a/src/router/src/Middleware/ThrottleRequests.php +++ b/src/router/src/Middleware/ThrottleRequests.php @@ -5,14 +5,14 @@ namespace Hypervel\Router\Middleware; use Closure; -use Hyperf\Collection\Arr; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Cache\Exceptions\InvalidArgumentException; use Hypervel\Cache\RateLimiter; use Hypervel\Cache\RateLimiting\Unlimited; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Cache\InvalidArgumentException; use Hypervel\HttpMessage\Exceptions\HttpResponseException; use Hypervel\HttpMessage\Exceptions\ThrottleRequestsException; +use Hypervel\Support\Arr; use Hypervel\Support\Facades\Auth; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; diff --git a/src/router/src/Middleware/ValidateSignature.php b/src/router/src/Middleware/ValidateSignature.php index a0813afad..5b79945f6 100644 --- a/src/router/src/Middleware/ValidateSignature.php +++ b/src/router/src/Middleware/ValidateSignature.php @@ -4,9 +4,9 @@ namespace Hypervel\Router\Middleware; -use Hyperf\Collection\Arr; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Http\Request as RequestContract; use Hypervel\Router\Exceptions\InvalidSignatureException; +use Hypervel\Support\Arr; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; diff --git a/src/router/src/RouteCollector.php b/src/router/src/RouteCollector.php index 5980d6ba6..429750d91 100644 --- a/src/router/src/RouteCollector.php +++ b/src/router/src/RouteCollector.php @@ -5,9 +5,9 @@ namespace Hypervel\Router; use Closure; -use Hyperf\Collection\Arr; use Hyperf\HttpServer\MiddlewareManager; use Hyperf\HttpServer\Router\RouteCollector as BaseRouteCollector; +use Hypervel\Support\Arr; use InvalidArgumentException; class RouteCollector extends BaseRouteCollector diff --git a/src/router/src/Router.php b/src/router/src/Router.php index 205aba79c..bf838aa4b 100644 --- a/src/router/src/Router.php +++ b/src/router/src/Router.php @@ -6,11 +6,11 @@ use Closure; use Hyperf\Context\ApplicationContext; -use Hyperf\Database\Model\Model; use Hyperf\HttpServer\Request; use Hyperf\HttpServer\Router\Dispatched; use Hyperf\HttpServer\Router\DispatcherFactory; use Hyperf\HttpServer\Router\RouteCollector; +use Hypervel\Database\Eloquent\Model; use Hypervel\Http\DispatchedRoute; use RuntimeException; diff --git a/src/router/src/UrlGenerator.php b/src/router/src/UrlGenerator.php index 68f080e1c..b3fa51dad 100644 --- a/src/router/src/UrlGenerator.php +++ b/src/router/src/UrlGenerator.php @@ -9,7 +9,6 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Collection\Arr; use Hyperf\Context\Context; use Hyperf\Context\RequestContext; use Hyperf\Contract\ConfigInterface; @@ -18,11 +17,12 @@ use Hyperf\HttpMessage\Uri\Uri; use Hyperf\HttpServer\Contract\RequestInterface; use Hyperf\HttpServer\Router\DispatcherFactory; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Str; use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; -use Hypervel\Router\Contracts\UrlRoutable; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Router\UrlRoutable; +use Hypervel\Support\Arr; +use Hypervel\Support\Str; +use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; class UrlGenerator implements UrlGeneratorContract diff --git a/src/sanctum/composer.json b/src/sanctum/composer.json index e2d6f5ced..db24d41d8 100644 --- a/src/sanctum/composer.json +++ b/src/sanctum/composer.json @@ -20,7 +20,7 @@ ], "require": { "php": "^8.2", - "hyperf/database": "~3.1.0", + "hypervel/database": "^0.3", "hyperf/http-server": "~3.1.0", "hypervel/auth": "^0.3", "hypervel/console": "^0.3", diff --git a/src/sanctum/database/migrations/2023_08_03_000000_create_personal_access_tokens_table.php b/src/sanctum/database/migrations/2023_08_03_000000_create_personal_access_tokens_table.php index f5e7b20ac..168cd2745 100644 --- a/src/sanctum/database/migrations/2023_08_03_000000_create_personal_access_tokens_table.php +++ b/src/sanctum/database/migrations/2023_08_03_000000_create_personal_access_tokens_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration { diff --git a/src/sanctum/src/Contracts/HasApiTokens.php b/src/sanctum/src/Contracts/HasApiTokens.php index ca41b2dc2..6f7be6527 100644 --- a/src/sanctum/src/Contracts/HasApiTokens.php +++ b/src/sanctum/src/Contracts/HasApiTokens.php @@ -5,7 +5,7 @@ namespace Hypervel\Sanctum\Contracts; use DateTimeInterface; -use Hyperf\Database\Model\Relations\MorphMany; +use Hypervel\Database\Eloquent\Relations\MorphMany; use UnitEnum; interface HasApiTokens diff --git a/src/sanctum/src/HasApiTokens.php b/src/sanctum/src/HasApiTokens.php index 83c57e909..4671fef65 100644 --- a/src/sanctum/src/HasApiTokens.php +++ b/src/sanctum/src/HasApiTokens.php @@ -5,7 +5,7 @@ namespace Hypervel\Sanctum; use DateTimeInterface; -use Hyperf\Database\Model\Relations\MorphMany; +use Hypervel\Database\Eloquent\Relations\MorphMany; use Hypervel\Sanctum\Contracts\HasAbilities; use Hypervel\Support\Str; use UnitEnum; diff --git a/src/sanctum/src/Http/Middleware/AuthenticateSession.php b/src/sanctum/src/Http/Middleware/AuthenticateSession.php index 68b4b575f..b37f97ba3 100644 --- a/src/sanctum/src/Http/Middleware/AuthenticateSession.php +++ b/src/sanctum/src/Http/Middleware/AuthenticateSession.php @@ -4,12 +4,12 @@ namespace Hypervel\Sanctum\Http\Middleware; -use Hyperf\Collection\Collection; use Hypervel\Auth\AuthenticationException; -use Hypervel\Auth\Contracts\Factory as AuthFactory; use Hypervel\Auth\Guards\SessionGuard; -use Hypervel\Session\Contracts\Session; +use Hypervel\Contracts\Auth\Factory as AuthFactory; +use Hypervel\Contracts\Session\Session; use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; diff --git a/src/sanctum/src/Http/Middleware/CheckAbilities.php b/src/sanctum/src/Http/Middleware/CheckAbilities.php index dcf52ffff..e9c8bc551 100644 --- a/src/sanctum/src/Http/Middleware/CheckAbilities.php +++ b/src/sanctum/src/Http/Middleware/CheckAbilities.php @@ -6,7 +6,7 @@ use Closure; use Hypervel\Auth\AuthenticationException; -use Hypervel\Auth\Contracts\Factory as AuthFactory; +use Hypervel\Contracts\Auth\Factory as AuthFactory; use Hypervel\Sanctum\Exceptions\MissingAbilityException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; diff --git a/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php b/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php index 7765eed57..7af92efe6 100644 --- a/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php +++ b/src/sanctum/src/Http/Middleware/CheckForAnyAbility.php @@ -6,7 +6,7 @@ use Closure; use Hypervel\Auth\AuthenticationException; -use Hypervel\Auth\Contracts\Factory as AuthFactory; +use Hypervel\Contracts\Auth\Factory as AuthFactory; use Hypervel\Sanctum\Exceptions\MissingAbilityException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; diff --git a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php index 0f32de865..08df85edf 100644 --- a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php +++ b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php @@ -4,10 +4,10 @@ namespace Hypervel\Sanctum\Http\Middleware; -use Hyperf\Collection\Collection; use Hyperf\HttpServer\Contract\RequestInterface; use Hyperf\HttpServer\Contract\ResponseInterface as HttpResponse; use Hypervel\Dispatcher\Pipeline; +use Hypervel\Support\Collection; use Hypervel\Support\Str; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; diff --git a/src/sanctum/src/NewAccessToken.php b/src/sanctum/src/NewAccessToken.php index 63e7f6240..60772b08c 100644 --- a/src/sanctum/src/NewAccessToken.php +++ b/src/sanctum/src/NewAccessToken.php @@ -4,8 +4,8 @@ namespace Hypervel\Sanctum; -use Hypervel\Support\Contracts\Arrayable; -use Hypervel\Support\Contracts\Jsonable; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; use Stringable; class NewAccessToken implements Stringable, Arrayable, Jsonable diff --git a/src/sanctum/src/PersonalAccessToken.php b/src/sanctum/src/PersonalAccessToken.php index d6b647db5..5682e44a7 100644 --- a/src/sanctum/src/PersonalAccessToken.php +++ b/src/sanctum/src/PersonalAccessToken.php @@ -4,14 +4,12 @@ namespace Hypervel\Sanctum; -use Hyperf\Database\Model\Events\Deleting; -use Hyperf\Database\Model\Events\Updating; -use Hyperf\Database\Model\Relations\MorphTo; -use Hypervel\Auth\Contracts\Authenticatable; use Hypervel\Cache\CacheManager; -use Hypervel\Cache\Contracts\Repository as CacheRepository; use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Sanctum\Contracts\HasAbilities; use UnitEnum; @@ -24,7 +22,7 @@ * @property string $name * @property null|\Carbon\Carbon $last_used_at * @property null|\Carbon\Carbon $expires_at - * @method static \Hyperf\Database\Model\Builder where(string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and') + * @method static \Hypervel\Database\Eloquent\Builder where(string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and') * @method static static|null find(mixed $id, array $columns = ['*']) */ class PersonalAccessToken extends Model implements HasAbilities @@ -63,24 +61,21 @@ class PersonalAccessToken extends Model implements HasAbilities 'token', ]; - /** - * Handle the updating event. - */ - public function updating(Updating $event): void + protected static function boot(): void { - if (config('sanctum.cache.enabled')) { - self::clearTokenCache($this->id); - } - } - - /** - * Handle the deleting event. - */ - public function deleting(Deleting $event): void - { - if (config('sanctum.cache.enabled')) { - self::clearTokenCache($this->id); - } + parent::boot(); + + static::updating(function ($model) { + if (config('sanctum.cache.enabled')) { + self::clearTokenCache($model->id); + } + }); + + static::deleting(function ($model) { + if (config('sanctum.cache.enabled')) { + self::clearTokenCache($model->id); + } + }); } /** diff --git a/src/sanctum/src/Sanctum.php b/src/sanctum/src/Sanctum.php index 7a354df0b..6f447e683 100644 --- a/src/sanctum/src/Sanctum.php +++ b/src/sanctum/src/Sanctum.php @@ -48,7 +48,7 @@ public static function currentApplicationUrlWithPort(): string /** * Set the current user for the application with the given abilities. * - * @param \Hypervel\Auth\Contracts\Authenticatable&\Hypervel\Sanctum\Contracts\HasApiTokens $user + * @param \Hypervel\Contracts\Auth\Authenticatable&\Hypervel\Sanctum\Contracts\HasApiTokens $user * @param array $abilities */ public static function actingAs($user, array $abilities = [], string $guard = 'sanctum'): mixed diff --git a/src/sanctum/src/SanctumGuard.php b/src/sanctum/src/SanctumGuard.php index bec3bdebc..fd28500b2 100644 --- a/src/sanctum/src/SanctumGuard.php +++ b/src/sanctum/src/SanctumGuard.php @@ -8,14 +8,14 @@ use Hyperf\Context\Context; use Hyperf\Context\RequestContext; use Hyperf\HttpServer\Contract\RequestInterface; -use Hyperf\Macroable\Macroable; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Factory as AuthFactory; -use Hypervel\Auth\Contracts\Guard as GuardContract; -use Hypervel\Auth\Contracts\UserProvider; use Hypervel\Auth\Guards\GuardHelpers; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\Factory as AuthFactory; +use Hypervel\Contracts\Auth\Guard as GuardContract; +use Hypervel\Contracts\Auth\UserProvider; use Hypervel\Sanctum\Events\TokenAuthenticated; use Hypervel\Support\Arr; +use Hypervel\Support\Traits\Macroable; use Psr\EventDispatcher\EventDispatcherInterface; class SanctumGuard implements GuardContract @@ -72,7 +72,7 @@ public function user(): ?Authenticatable $tokenable = $model::findTokenable($accessToken); if ($this->supportsTokens($tokenable)) { - /** @var \Hypervel\Auth\Contracts\Authenticatable&\Hypervel\Sanctum\Contracts\HasApiTokens $tokenable */ + /** @var \Hypervel\Contracts\Auth\Authenticatable&\Hypervel\Sanctum\Contracts\HasApiTokens $tokenable */ $user = $tokenable->withAccessToken($accessToken); // Dispatch event if event dispatcher is available diff --git a/src/sentry/src/Factory/ClientBuilderFactory.php b/src/sentry/src/Factory/ClientBuilderFactory.php index 4f8e8e603..9a96288d2 100644 --- a/src/sentry/src/Factory/ClientBuilderFactory.php +++ b/src/sentry/src/Factory/ClientBuilderFactory.php @@ -5,7 +5,7 @@ namespace Hypervel\Sentry\Factory; use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Contracts\Application; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Sentry\Integrations\ExceptionContextIntegration; use Hypervel\Sentry\Integrations\Integration; use Hypervel\Sentry\Integrations\RequestFetcher; @@ -18,8 +18,6 @@ use Sentry\Integration as SdkIntegration; use function Hyperf\Support\make; -use function Hyperf\Tappable\tap; -use function Hypervel\Support\env; class ClientBuilderFactory { diff --git a/src/sentry/src/Factory/HubFactory.php b/src/sentry/src/Factory/HubFactory.php index dd63902eb..eec58f513 100644 --- a/src/sentry/src/Factory/HubFactory.php +++ b/src/sentry/src/Factory/HubFactory.php @@ -4,7 +4,7 @@ namespace Hypervel\Sentry\Factory; -use Hypervel\Foundation\Contracts\Application; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Sentry\Hub; use Sentry\State\HubInterface; diff --git a/src/sentry/src/Features/CacheFeature.php b/src/sentry/src/Features/CacheFeature.php index 575c8556b..823970036 100644 --- a/src/sentry/src/Features/CacheFeature.php +++ b/src/sentry/src/Features/CacheFeature.php @@ -18,12 +18,12 @@ use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; use Hypervel\Cache\Events\WritingManyKeys; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; +use Hypervel\Contracts\Session\Session; use Hypervel\Sentry\Integrations\Integration; use Hypervel\Sentry\Traits\ResolvesEventOrigin; use Hypervel\Sentry\Traits\TracksPushedScopesAndSpans; use Hypervel\Sentry\Traits\WorksWithSpans; -use Hypervel\Session\Contracts\Session; use Sentry\Breadcrumb; use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; diff --git a/src/sentry/src/Features/ConsoleSchedulingFeature.php b/src/sentry/src/Features/ConsoleSchedulingFeature.php index f0b60b294..a9d1fa58d 100644 --- a/src/sentry/src/Features/ConsoleSchedulingFeature.php +++ b/src/sentry/src/Features/ConsoleSchedulingFeature.php @@ -5,14 +5,14 @@ namespace Hypervel\Sentry\Features; use DateTimeZone; -use Hypervel\Cache\Contracts\Factory as Cache; -use Hypervel\Cache\Contracts\Repository; use Hypervel\Console\Application as ConsoleApplication; use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Events\ScheduledTaskStarting; use Hypervel\Console\Scheduling\Event as SchedulingEvent; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Cache\Factory as Cache; +use Hypervel\Contracts\Cache\Repository; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Sentry\Traits\TracksPushedScopesAndSpans; use Hypervel\Support\Str; use RuntimeException; diff --git a/src/sentry/src/Features/DbQueryFeature.php b/src/sentry/src/Features/DbQueryFeature.php index 424ac5866..0a955ca49 100644 --- a/src/sentry/src/Features/DbQueryFeature.php +++ b/src/sentry/src/Features/DbQueryFeature.php @@ -4,12 +4,12 @@ namespace Hypervel\Sentry\Features; -use Hyperf\Database\Events\ConnectionEvent; -use Hyperf\Database\Events\QueryExecuted; -use Hyperf\Database\Events\TransactionBeginning; -use Hyperf\Database\Events\TransactionCommitted; -use Hyperf\Database\Events\TransactionRolledBack; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; +use Hypervel\Database\Events\ConnectionEvent; +use Hypervel\Database\Events\QueryExecuted; +use Hypervel\Database\Events\TransactionBeginning; +use Hypervel\Database\Events\TransactionCommitted; +use Hypervel\Database\Events\TransactionRolledBack; use Hypervel\Sentry\Integrations\Integration; use Sentry\Breadcrumb; diff --git a/src/sentry/src/Features/NotificationsFeature.php b/src/sentry/src/Features/NotificationsFeature.php index 5d6b20eb7..80dbbf6c3 100644 --- a/src/sentry/src/Features/NotificationsFeature.php +++ b/src/sentry/src/Features/NotificationsFeature.php @@ -4,8 +4,8 @@ namespace Hypervel\Sentry\Features; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Database\Eloquent\Model; -use Hypervel\Event\Contracts\Dispatcher; use Hypervel\Notifications\Events\NotificationSending; use Hypervel\Notifications\Events\NotificationSent; use Hypervel\Sentry\Integrations\Integration; diff --git a/src/sentry/src/Features/QueueFeature.php b/src/sentry/src/Features/QueueFeature.php index bec3df942..19ece012f 100644 --- a/src/sentry/src/Features/QueueFeature.php +++ b/src/sentry/src/Features/QueueFeature.php @@ -5,7 +5,7 @@ namespace Hypervel\Sentry\Features; use Closure; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobProcessed; @@ -227,7 +227,10 @@ public function handleJobProcessingQueueEvent(JobProcessing $event): void public function handleJobFailedEvent(JobFailed $event): void { $this->maybeFinishSpan(SpanStatus::internalError()); - $this->maybePopScope(); + + // Don't pop scope here - breadcrumbs need to remain available for exception + // reporting. The next JobProcessing event will clean up via its maybePopScope() + // call before pushing a new scope. } public function handleWorkerStoppingQueueEvent(WorkerStopping $event): void diff --git a/src/sentry/src/Features/RedisFeature.php b/src/sentry/src/Features/RedisFeature.php index deedc828b..4db03e02f 100644 --- a/src/sentry/src/Features/RedisFeature.php +++ b/src/sentry/src/Features/RedisFeature.php @@ -8,10 +8,10 @@ use Hyperf\Contract\ConfigInterface; use Hyperf\Redis\Event\CommandExecuted; use Hyperf\Redis\Pool\PoolFactory; +use Hypervel\Contracts\Event\Dispatcher; +use Hypervel\Contracts\Session\Session; use Hypervel\Coroutine\Coroutine; -use Hypervel\Event\Contracts\Dispatcher; use Hypervel\Sentry\Traits\ResolvesEventOrigin; -use Hypervel\Session\Contracts\Session; use Hypervel\Support\Str; use Sentry\SentrySdk; use Sentry\Tracing\SpanContext; diff --git a/src/sentry/src/HttpClient/HttpClientFactory.php b/src/sentry/src/HttpClient/HttpClientFactory.php index d4675eed1..8208840cf 100644 --- a/src/sentry/src/HttpClient/HttpClientFactory.php +++ b/src/sentry/src/HttpClient/HttpClientFactory.php @@ -4,7 +4,7 @@ namespace Hypervel\Sentry\HttpClient; -use Hypervel\Foundation\Contracts\Application; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Sentry\Version; class HttpClientFactory diff --git a/src/session/composer.json b/src/session/composer.json index b624812cd..509d555f1 100644 --- a/src/session/composer.json +++ b/src/session/composer.json @@ -32,11 +32,9 @@ "php": "^8.2", "ext-session": "*", "hyperf/context": "~3.1.0", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", "hyperf/support": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/macroable": "~3.1.0", - "hyperf/tappable": "~3.1.0", + "hypervel/macroable": "~0.3.0", "hyperf/view-engine": "~3.1.0", "hypervel/cache": "^0.3", "hypervel/cookie": "^0.3", diff --git a/src/session/publish/session.php b/src/session/publish/session.php index 3628e8b03..db82491d8 100644 --- a/src/session/publish/session.php +++ b/src/session/publish/session.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; use function Hyperf\Support\env; diff --git a/src/session/src/AdapterFactory.php b/src/session/src/AdapterFactory.php index 263a3a351..9f9fa0f18 100644 --- a/src/session/src/AdapterFactory.php +++ b/src/session/src/AdapterFactory.php @@ -5,7 +5,7 @@ namespace Hypervel\Session; use Hyperf\Contract\SessionInterface; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Session\Session as SessionContract; use Psr\Container\ContainerInterface; class AdapterFactory diff --git a/src/session/src/ArraySessionHandler.php b/src/session/src/ArraySessionHandler.php index 5e1742cfa..1bdc65f82 100644 --- a/src/session/src/ArraySessionHandler.php +++ b/src/session/src/ArraySessionHandler.php @@ -4,7 +4,7 @@ namespace Hypervel\Session; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Support\InteractsWithTime; use SessionHandlerInterface; class ArraySessionHandler implements SessionHandlerInterface diff --git a/src/session/src/CacheBasedSessionHandler.php b/src/session/src/CacheBasedSessionHandler.php index 680abb054..a58365acd 100644 --- a/src/session/src/CacheBasedSessionHandler.php +++ b/src/session/src/CacheBasedSessionHandler.php @@ -4,8 +4,8 @@ namespace Hypervel\Session; -use Hypervel\Cache\Contracts\Factory as CacheContract; -use Hypervel\Cache\Contracts\Repository as RepositoryContract; +use Hypervel\Contracts\Cache\Factory as CacheContract; +use Hypervel\Contracts\Cache\Repository as RepositoryContract; use SessionHandlerInterface; class CacheBasedSessionHandler implements SessionHandlerInterface diff --git a/src/session/src/ConfigProvider.php b/src/session/src/ConfigProvider.php index bf7c55474..95a58181e 100644 --- a/src/session/src/ConfigProvider.php +++ b/src/session/src/ConfigProvider.php @@ -5,8 +5,8 @@ namespace Hypervel\Session; use Hyperf\Contract\SessionInterface; -use Hypervel\Session\Contracts\Factory; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Session\Factory; +use Hypervel\Contracts\Session\Session as SessionContract; class ConfigProvider { diff --git a/src/session/src/CookieSessionHandler.php b/src/session/src/CookieSessionHandler.php index 8e1777e83..f30a4ede9 100644 --- a/src/session/src/CookieSessionHandler.php +++ b/src/session/src/CookieSessionHandler.php @@ -5,8 +5,8 @@ namespace Hypervel\Session; use Hyperf\HttpServer\Request; -use Hypervel\Cookie\Contracts\Cookie as CookieContract; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Contracts\Cookie\Cookie as CookieContract; +use Hypervel\Support\InteractsWithTime; use SessionHandlerInterface; class CookieSessionHandler implements SessionHandlerInterface diff --git a/src/session/src/DatabaseSessionHandler.php b/src/session/src/DatabaseSessionHandler.php index 48009c777..3bbc9e1e1 100644 --- a/src/session/src/DatabaseSessionHandler.php +++ b/src/session/src/DatabaseSessionHandler.php @@ -5,21 +5,19 @@ namespace Hypervel\Session; use Carbon\Carbon; -use Hyperf\Collection\Arr; use Hyperf\Context\Context; use Hyperf\Context\RequestContext; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Exception\QueryException; -use Hyperf\Database\Query\Builder; use Hyperf\HttpServer\Request; -use Hypervel\Auth\Contracts\Guard; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Contracts\Auth\Guard; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; +use Hypervel\Database\QueryException; +use Hypervel\Support\Arr; +use Hypervel\Support\InteractsWithTime; use Psr\Container\ContainerInterface; use SessionHandlerInterface; -use function Hyperf\Tappable\tap; - class DatabaseSessionHandler implements ExistenceAwareInterface, SessionHandlerInterface { use InteractsWithTime; diff --git a/src/session/src/EncryptedStore.php b/src/session/src/EncryptedStore.php index d0103ec6c..1fa7d69a1 100644 --- a/src/session/src/EncryptedStore.php +++ b/src/session/src/EncryptedStore.php @@ -4,8 +4,8 @@ namespace Hypervel\Session; -use Hypervel\Encryption\Contracts\Encrypter as EncrypterContract; -use Hypervel\Encryption\Exceptions\DecryptException; +use Hypervel\Contracts\Encryption\DecryptException; +use Hypervel\Contracts\Encryption\Encrypter as EncrypterContract; use SessionHandlerInterface; class EncryptedStore extends Store diff --git a/src/session/src/Functions.php b/src/session/src/Functions.php index 1d7152736..88199bc5a 100644 --- a/src/session/src/Functions.php +++ b/src/session/src/Functions.php @@ -4,7 +4,7 @@ namespace Hypervel\Session; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Support\HtmlString; use RuntimeException; diff --git a/src/session/src/Middleware/StartSession.php b/src/session/src/Middleware/StartSession.php index af0d74ff5..b8fc67875 100644 --- a/src/session/src/Middleware/StartSession.php +++ b/src/session/src/Middleware/StartSession.php @@ -10,9 +10,9 @@ use Hyperf\Contract\SessionInterface; use Hyperf\HttpServer\Request; use Hyperf\HttpServer\Router\Dispatched; -use Hypervel\Cache\Contracts\Factory as CacheFactoryContract; +use Hypervel\Contracts\Cache\Factory as CacheFactoryContract; +use Hypervel\Contracts\Session\Session; use Hypervel\Cookie\Cookie; -use Hypervel\Session\Contracts\Session; use Hypervel\Session\SessionManager; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; diff --git a/src/session/src/SessionAdapter.php b/src/session/src/SessionAdapter.php index 6bbd91e3a..ad17cd92a 100644 --- a/src/session/src/SessionAdapter.php +++ b/src/session/src/SessionAdapter.php @@ -5,7 +5,7 @@ namespace Hypervel\Session; use Hyperf\Contract\SessionInterface; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Session\Session as SessionContract; use Psr\Container\ContainerInterface; use RuntimeException; diff --git a/src/session/src/SessionManager.php b/src/session/src/SessionManager.php index d456ae626..3026c2c63 100644 --- a/src/session/src/SessionManager.php +++ b/src/session/src/SessionManager.php @@ -4,14 +4,14 @@ namespace Hypervel\Session; -use Hyperf\Database\ConnectionResolverInterface; use Hyperf\HttpServer\Request; use Hyperf\Support\Filesystem\Filesystem; -use Hypervel\Cache\Contracts\Factory as CacheContract; -use Hypervel\Cookie\Contracts\Cookie as CookieContract; -use Hypervel\Encryption\Contracts\Encrypter; -use Hypervel\Session\Contracts\Factory; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Cache\Factory as CacheContract; +use Hypervel\Contracts\Cookie\Cookie as CookieContract; +use Hypervel\Contracts\Encryption\Encrypter; +use Hypervel\Contracts\Session\Factory; +use Hypervel\Contracts\Session\Session as SessionContract; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Support\Manager; use SessionHandlerInterface; diff --git a/src/session/src/Store.php b/src/session/src/Store.php index 13c668ed4..3ec9f0a42 100644 --- a/src/session/src/Store.php +++ b/src/session/src/Store.php @@ -5,13 +5,13 @@ namespace Hypervel\Session; use Closure; -use Hyperf\Collection\Arr; use Hyperf\Context\Context; -use Hyperf\Macroable\Macroable; use Hyperf\Support\MessageBag; use Hyperf\ViewEngine\ViewErrorBag; -use Hypervel\Session\Contracts\Session; +use Hypervel\Contracts\Session\Session; +use Hypervel\Support\Arr; use Hypervel\Support\Str; +use Hypervel\Support\Traits\Macroable; use SessionHandlerInterface; use stdClass; use UnitEnum; diff --git a/src/session/src/StoreFactory.php b/src/session/src/StoreFactory.php index 4669d5935..20b5b9903 100644 --- a/src/session/src/StoreFactory.php +++ b/src/session/src/StoreFactory.php @@ -4,8 +4,8 @@ namespace Hypervel\Session; -use Hypervel\Session\Contracts\Factory; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Session\Factory; +use Hypervel\Contracts\Session\Session as SessionContract; use Psr\Container\ContainerInterface; class StoreFactory diff --git a/src/socialite/src/Facades/Socialite.php b/src/socialite/src/Facades/Socialite.php index 1023a7cbe..86c286e73 100644 --- a/src/socialite/src/Facades/Socialite.php +++ b/src/socialite/src/Facades/Socialite.php @@ -27,7 +27,7 @@ * @method static \Hypervel\Socialite\Two\AbstractProvider setScopes(array|string $scopes) * @method static array getScopes() * @method static \Hypervel\Socialite\Two\AbstractProvider redirectUrl(string $url) - * @method static \Hypervel\Socialite\Two\AbstractProvider setRequest(\Hypervel\Http\Contracts\RequestContract $request) + * @method static \Hypervel\Socialite\Two\AbstractProvider setRequest(\Hypervel\Contracts\Http\Request $request) * @method static \Hypervel\Socialite\Two\AbstractProvider stateless() * @method static \Hypervel\Socialite\Two\AbstractProvider enablePKCE() * @method static mixed getContext(string $key, mixed $default = null) diff --git a/src/socialite/src/One/AbstractProvider.php b/src/socialite/src/One/AbstractProvider.php index a9c78da2c..b3134dd52 100644 --- a/src/socialite/src/One/AbstractProvider.php +++ b/src/socialite/src/One/AbstractProvider.php @@ -4,8 +4,8 @@ namespace Hypervel\Socialite\One; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Hypervel\Socialite\Contracts\Provider as ProviderContract; use Hypervel\Socialite\HasProviderContext; use League\OAuth1\Client\Credentials\TokenCredentials; diff --git a/src/socialite/src/SocialiteManager.php b/src/socialite/src/SocialiteManager.php index fcc157f72..f43d617a8 100644 --- a/src/socialite/src/SocialiteManager.php +++ b/src/socialite/src/SocialiteManager.php @@ -4,9 +4,9 @@ namespace Hypervel\Socialite; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; use Hypervel\Socialite\Exceptions\DriverMissingConfigurationException; use Hypervel\Socialite\Two\BitbucketProvider; use Hypervel\Socialite\Two\FacebookProvider; diff --git a/src/socialite/src/Two/AbstractProvider.php b/src/socialite/src/Two/AbstractProvider.php index ba2b15d63..115ab52de 100644 --- a/src/socialite/src/Two/AbstractProvider.php +++ b/src/socialite/src/Two/AbstractProvider.php @@ -6,8 +6,8 @@ use GuzzleHttp\Client; use GuzzleHttp\RequestOptions; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Hypervel\Socialite\Contracts\Provider as ProviderContract; use Hypervel\Socialite\HasProviderContext; use Hypervel\Socialite\Two\Exceptions\InvalidStateException; diff --git a/src/support/composer.json b/src/support/composer.json index 61a69cf9f..f94ef4826 100644 --- a/src/support/composer.json +++ b/src/support/composer.json @@ -23,11 +23,15 @@ "php": "^8.2", "hyperf/context": "~3.1.0", "hyperf/support": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/tappable": "~3.1.0", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", + "hypervel/conditionable": "~0.3.0", + "hypervel/macroable": "~0.3.0", + "hypervel/reflection": "~0.3.0", + "laravel/serializable-closure": "^1.3", + "league/uri": "^7.5", "nesbot/carbon": "^2.72.6", - "league/uri": "^7.5" + "symfony/uid": "^7.4", + "voku/portable-ascii": "^2.0" }, "autoload": { "psr-4": { @@ -47,4 +51,4 @@ "sort-packages": true }, "minimum-stability": "dev" -} \ No newline at end of file +} diff --git a/src/support/src/Arr.php b/src/support/src/Arr.php deleted file mode 100644 index 1f7d270f7..000000000 --- a/src/support/src/Arr.php +++ /dev/null @@ -1,11 +0,0 @@ - */ + protected static array $customCodecs = []; + + /** + * Register a custom codec. + */ + public static function register(string $name, callable $encode, callable $decode): void + { + self::$customCodecs[$name] = [ + 'encode' => $encode, + 'decode' => $decode, + ]; + } + + /** + * Encode a value to binary. + */ + public static function encode(UuidInterface|Ulid|string|null $value, string $format): ?string + { + if (blank($value)) { + return null; + } + + if (isset(self::$customCodecs[$format])) { + return (self::$customCodecs[$format]['encode'])($value); + } + + return match ($format) { + 'uuid' => match (true) { + $value instanceof UuidInterface => $value->getBytes(), + self::isBinary($value) => $value, + default => Uuid::fromString($value)->getBytes(), + }, + 'ulid' => match (true) { + $value instanceof Ulid => $value->toBinary(), + self::isBinary($value) => $value, + default => Ulid::fromString($value)->toBinary(), + }, + default => throw new InvalidArgumentException("Format [{$format}] is invalid."), + }; + } + + /** + * Decode a binary value to string. + */ + public static function decode(?string $value, string $format): ?string + { + if (blank($value)) { + return null; + } + + if (isset(self::$customCodecs[$format])) { + return (self::$customCodecs[$format]['decode'])($value); + } + + return match ($format) { + 'uuid' => (self::isBinary($value) ? Uuid::fromBytes($value) : Uuid::fromString($value))->toString(), + 'ulid' => (self::isBinary($value) ? Ulid::fromBinary($value) : Ulid::fromString($value))->toString(), + default => throw new InvalidArgumentException("Format [{$format}] is invalid."), + }; + } + + /** + * Get all available format names. + * + * @return list + */ + public static function formats(): array + { + return array_unique([...['uuid', 'ulid'], ...array_keys(self::$customCodecs)]); + } + + /** + * Determine if the given value is binary data. + */ + public static function isBinary(mixed $value): bool + { + if (! is_string($value) || $value === '') { + return false; + } + + if (str_contains($value, "\0")) { + return true; + } + + return ! mb_check_encoding($value, 'UTF-8'); + } +} diff --git a/src/support/src/Collection.php b/src/support/src/Collection.php deleted file mode 100644 index 73f9285c8..000000000 --- a/src/support/src/Collection.php +++ /dev/null @@ -1,175 +0,0 @@ - - */ -class Collection extends BaseCollection -{ - use TransformsToResourceCollection; - - /** - * Group an associative array by a field or using a callback. - * - * Supports UnitEnum and Stringable keys, converting them to array keys. - */ - public function groupBy(mixed $groupBy, bool $preserveKeys = false): Enumerable - { - if (is_array($groupBy)) { - $nextGroups = $groupBy; - $groupBy = array_shift($nextGroups); - } - - $groupBy = $this->valueRetriever($groupBy); - $results = []; - - foreach ($this->items as $key => $value) { - $groupKeys = $groupBy($value, $key); - - if (! is_array($groupKeys)) { - $groupKeys = [$groupKeys]; - } - - foreach ($groupKeys as $groupKey) { - $groupKey = match (true) { - is_bool($groupKey) => (int) $groupKey, - $groupKey instanceof UnitEnum => enum_value($groupKey), - $groupKey instanceof Stringable => (string) $groupKey, - is_null($groupKey) => (string) $groupKey, - default => $groupKey, - }; - - if (! array_key_exists($groupKey, $results)) { - $results[$groupKey] = new static(); - } - - $results[$groupKey]->offsetSet($preserveKeys ? $key : null, $value); - } - } - - $result = new static($results); - - if (! empty($nextGroups)) { - return $result->map->groupBy($nextGroups, $preserveKeys); - } - - return $result; - } - - /** - * Key an associative array by a field or using a callback. - * - * Supports UnitEnum keys, converting them to array keys via enum_value(). - */ - public function keyBy(mixed $keyBy): static - { - $keyBy = $this->valueRetriever($keyBy); - $results = []; - - foreach ($this->items as $key => $item) { - $resolvedKey = $keyBy($item, $key); - - if ($resolvedKey instanceof UnitEnum) { - $resolvedKey = enum_value($resolvedKey); - } - - if (is_object($resolvedKey)) { - $resolvedKey = (string) $resolvedKey; - } - - $results[$resolvedKey] = $item; - } - - return new static($results); - } - - /** - * Get a lazy collection for the items in this collection. - * - * @return \Hypervel\Support\LazyCollection - */ - public function lazy(): LazyCollection - { - return new LazyCollection($this->items); - } - - /** - * Results array of items from Collection or Arrayable. - * - * @return array - */ - protected function getArrayableItems(mixed $items): array - { - if ($items instanceof UnitEnum) { - return [$items]; - } - - return parent::getArrayableItems($items); - } - - /** - * Get an operator checker callback. - * - * @param callable|string $key - * @param null|string $operator - */ - protected function operatorForWhere(mixed $key, mixed $operator = null, mixed $value = null): callable|Closure - { - if ($this->useAsCallable($key)) { - return $key; - } - - if (func_num_args() === 1) { - $value = true; - $operator = '='; - } - - if (func_num_args() === 2) { - $value = $operator; - $operator = '='; - } - - return function ($item) use ($key, $operator, $value) { - $retrieved = enum_value(data_get($item, $key)); - $value = enum_value($value); - - $strings = array_filter([$retrieved, $value], function ($value) { - return match (true) { - is_string($value) => true, - $value instanceof Stringable => true, - default => false, - }; - }); - - if (count($strings) < 2 && count(array_filter([$retrieved, $value], 'is_object')) == 1) { - return in_array($operator, ['!=', '<>', '!==']); - } - - return match ($operator) { - '=', '==' => $retrieved == $value, - '!=', '<>' => $retrieved != $value, - '<' => $retrieved < $value, - '>' => $retrieved > $value, - '<=' => $retrieved <= $value, - '>=' => $retrieved >= $value, - '===' => $retrieved === $value, - '!==' => $retrieved !== $value, - '<=>' => $retrieved <=> $value, - default => $retrieved == $value, - }; - }; - } -} diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php index 47670a93d..0578573db 100644 --- a/src/support/src/Composer.php +++ b/src/support/src/Composer.php @@ -5,7 +5,6 @@ namespace Hypervel\Support; use Composer\Autoload\ClassLoader; -use Hyperf\Collection\Collection; use Hypervel\Filesystem\Filesystem; use RuntimeException; use Symfony\Component\Process\Process; diff --git a/src/support/src/ConfigurationUrlParser.php b/src/support/src/ConfigurationUrlParser.php index 7a8d552a5..14248767e 100644 --- a/src/support/src/ConfigurationUrlParser.php +++ b/src/support/src/ConfigurationUrlParser.php @@ -4,7 +4,6 @@ namespace Hypervel\Support; -use Hyperf\Collection\Arr; use InvalidArgumentException; class ConfigurationUrlParser diff --git a/src/support/src/Contracts/Arrayable.php b/src/support/src/Contracts/Arrayable.php deleted file mode 100644 index 975c8d5a4..000000000 --- a/src/support/src/Contracts/Arrayable.php +++ /dev/null @@ -1,11 +0,0 @@ - + */ + protected static array $customAdapters = []; + + /** + * Enable the putenv adapter. + */ + public static function enablePutenv(): void + { + static::$putenv = true; + static::$repository = null; + } + + /** + * Disable the putenv adapter. + */ + public static function disablePutenv(): void + { + static::$putenv = false; + static::$repository = null; + } + + /** + * Register a custom adapter creator Closure. + */ + public static function extend(Closure $callback, ?string $name = null): void + { + if (! is_null($name)) { + static::$customAdapters[$name] = $callback; + } else { + static::$customAdapters[] = $callback; + } + + static::$repository = null; + } + + /** + * Get the environment repository instance. + */ + public static function getRepository(): RepositoryInterface + { + if (static::$repository === null) { + $builder = RepositoryBuilder::createWithDefaultAdapters(); + + if (static::$putenv) { + $builder = $builder->addAdapter(PutenvAdapter::class); + } + + foreach (static::$customAdapters as $adapter) { + $builder = $builder->addAdapter($adapter()); + } + + static::$repository = $builder->immutable()->make(); + } + + return static::$repository; + } + + /** + * Get the value of an environment variable. + */ + public static function get(string $key, mixed $default = null): mixed + { + return self::getOption($key)->getOrCall(fn () => value($default)); + } + + /** + * Get the value of a required environment variable. + * + * @throws RuntimeException + */ + public static function getOrFail(string $key): mixed + { + return self::getOption($key)->getOrThrow(new RuntimeException("Environment variable [{$key}] has no value.")); + } + + /** + * Write an array of key-value pairs to the environment file. + * + * @param array $variables + * + * @throws RuntimeException + * @throws FileNotFoundException + */ + public static function writeVariables(array $variables, string $pathToFile, bool $overwrite = false): void + { + $filesystem = new Filesystem(); + + if ($filesystem->missing($pathToFile)) { + throw new RuntimeException("The file [{$pathToFile}] does not exist."); + } + + $lines = explode(PHP_EOL, $filesystem->get($pathToFile)); + + foreach ($variables as $key => $value) { + $lines = self::addVariableToEnvContents($key, $value, $lines, $overwrite); + } + + $filesystem->put($pathToFile, implode(PHP_EOL, $lines)); + } + + /** + * Write a single key-value pair to the environment file. + * + * @throws RuntimeException + * @throws FileNotFoundException + */ + public static function writeVariable(string $key, mixed $value, string $pathToFile, bool $overwrite = false): void + { + $filesystem = new Filesystem(); + + if ($filesystem->missing($pathToFile)) { + throw new RuntimeException("The file [{$pathToFile}] does not exist."); + } + + $envContent = $filesystem->get($pathToFile); + + $lines = explode(PHP_EOL, $envContent); + $lines = self::addVariableToEnvContents($key, $value, $lines, $overwrite); + + $filesystem->put($pathToFile, implode(PHP_EOL, $lines)); + } + + /** + * Add a variable to the environment file contents. + * + * @param array $envLines + * @return array + */ + protected static function addVariableToEnvContents(string $key, mixed $value, array $envLines, bool $overwrite): array + { + $prefix = explode('_', $key)[0] . '_'; + $lastPrefixIndex = -1; + + $shouldQuote = preg_match('/^[a-zA-z0-9]+$/', $value) === 0; + + $lineToAddVariations = [ + $key . '=' . (is_string($value) ? self::prepareQuotedValue($value) : $value), + $key . '=' . $value, + ]; + + $lineToAdd = $shouldQuote ? $lineToAddVariations[0] : $lineToAddVariations[1]; + + if ($value === '') { + $lineToAdd = $key . '='; + } + + foreach ($envLines as $index => $line) { + if (str_starts_with($line, $prefix)) { + $lastPrefixIndex = $index; + } + + if (in_array($line, $lineToAddVariations)) { + // This exact line already exists, so we don't need to add it again. + return $envLines; + } + + if ($line === $key . '=') { + // If the value is empty, we can replace it with the new value. + $envLines[$index] = $lineToAdd; + + return $envLines; + } + + if (str_starts_with($line, $key . '=')) { + if (! $overwrite) { + return $envLines; + } + + $envLines[$index] = $lineToAdd; + + return $envLines; + } + } + + if ($lastPrefixIndex === -1) { + if (count($envLines) && $envLines[count($envLines) - 1] !== '') { + $envLines[] = ''; + } + + return array_merge($envLines, [$lineToAdd]); + } + + return array_merge( + array_slice($envLines, 0, $lastPrefixIndex + 1), + [$lineToAdd], + array_slice($envLines, $lastPrefixIndex + 1) + ); + } + + /** + * Get the possible option for this environment variable. + */ + protected static function getOption(string $key): Option + { + return Option::fromValue(static::getRepository()->get($key)) + ->map(function ($value) { + switch (strtolower($value)) { + case 'true': + case '(true)': + return true; + case 'false': + case '(false)': + return false; + case 'empty': + case '(empty)': + return ''; + case 'null': + case '(null)': + return; + } + + if (preg_match('/\A([\'"])(.*)\1\z/', $value, $matches)) { + return $matches[2]; + } + + return $value; + }); + } + + /** + * Wrap a string in quotes, choosing double or single quotes. + */ + protected static function prepareQuotedValue(string $input): string + { + return str_contains($input, '"') + ? "'" . self::addSlashesExceptFor($input, ['"']) . "'" + : '"' . self::addSlashesExceptFor($input, ["'"]) . '"'; + } + + /** + * Escape a string using addslashes, excluding the specified characters from being escaped. + * + * @param array $except + */ + protected static function addSlashesExceptFor(string $value, array $except = []): string + { + $escaped = addslashes($value); + + foreach ($except as $character) { + $escaped = str_replace('\\' . $character, $character, $escaped); + } + + return $escaped; + } +} diff --git a/src/support/src/Environment.php b/src/support/src/Environment.php index 3f7655cb3..9979b22c4 100644 --- a/src/support/src/Environment.php +++ b/src/support/src/Environment.php @@ -5,10 +5,7 @@ namespace Hypervel\Support; use BadMethodCallException; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Str; - -use function Hyperf\Support\env; +use Hypervel\Support\Traits\Macroable; /** * @method bool isTesting() diff --git a/src/support/src/Facades/App.php b/src/support/src/Facades/App.php index 7430da72b..2cc12b795 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -74,8 +74,8 @@ * @method static void forgetInstance(string $abstract) * @method static void forgetInstances() * @method static void flush() - * @method static \Hypervel\Container\Contracts\Container getInstance() - * @method static \Hypervel\Container\Contracts\Container setInstance(\Hypervel\Container\Contracts\Container $container) + * @method static \Hypervel\Contracts\Container\Container getInstance() + * @method static \Hypervel\Contracts\Container\Container setInstance(\Hypervel\Contracts\Container\Container $container) * @method static void macro(string $name, callable|object $macro) * @method static void mixin(object $mixin, bool $replace = true) * @method static bool hasMacro(string $name) diff --git a/src/support/src/Facades/Artisan.php b/src/support/src/Facades/Artisan.php index 6d2c95eb2..731f5c2a9 100644 --- a/src/support/src/Facades/Artisan.php +++ b/src/support/src/Facades/Artisan.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; /** * @method static void bootstrap() @@ -12,18 +12,18 @@ * @method static void commands() * @method static \Hypervel\Console\ClosureCommand command(string $signature, \Closure $callback) * @method static void load(array|string $paths) - * @method static \Hypervel\Foundation\Console\Contracts\Kernel addCommands(array $commands) - * @method static \Hypervel\Foundation\Console\Contracts\Kernel addCommandPaths(array $paths) - * @method static \Hypervel\Foundation\Console\Contracts\Kernel addCommandRoutePaths(array $paths) + * @method static \Hypervel\Contracts\Console\Kernel addCommands(array $commands) + * @method static \Hypervel\Contracts\Console\Kernel addCommandPaths(array $paths) + * @method static \Hypervel\Contracts\Console\Kernel addCommandRoutePaths(array $paths) * @method static array getLoadedPaths() * @method static void registerCommand(string $command) * @method static void call(string $command, array $parameters = [], \Symfony\Component\Console\Output\OutputInterface|null $outputBuffer = null) * @method static array all() * @method static string output() - * @method static void setArtisan(\Hypervel\Console\Contracts\Application $artisan) - * @method static \Hypervel\Console\Contracts\Application getArtisan() + * @method static void setArtisan(\Hypervel\Contracts\Console\Application $artisan) + * @method static \Hypervel\Contracts\Console\Application getArtisan() * - * @see \Hypervel\Foundation\Console\Contracts\Kernel + * @see \Hypervel\Contracts\Console\Kernel */ class Artisan extends Facade { diff --git a/src/support/src/Facades/Auth.php b/src/support/src/Facades/Auth.php index 31c130b26..d51f2dc4a 100644 --- a/src/support/src/Facades/Auth.php +++ b/src/support/src/Facades/Auth.php @@ -5,10 +5,10 @@ namespace Hypervel\Support\Facades; use Hypervel\Auth\AuthManager; -use Hypervel\Auth\Contracts\Guard; +use Hypervel\Contracts\Auth\Guard; /** - * @method static \Hypervel\Auth\Contracts\Guard|\Hypervel\Auth\Contracts\StatefulGuard guard(string|null $name = null) + * @method static \Hypervel\Contracts\Auth\Guard|\Hypervel\Contracts\Auth\StatefulGuard guard(string|null $name = null) * @method static \Hypervel\Auth\Guards\SessionGuard createSessionDriver(string $name, array $config) * @method static \Hypervel\Auth\Guards\JwtGuard createJwtDriver(string $name, array $config) * @method static \Hypervel\Auth\AuthManager extend(string $driver, \Closure $callback) @@ -21,24 +21,24 @@ * @method static \Hypervel\Auth\AuthManager resolveUsersUsing(\Closure $userResolver) * @method static array getGuards() * @method static \Hypervel\Auth\AuthManager setApplication(\Psr\Container\ContainerInterface $app) - * @method static \Hypervel\Auth\Contracts\UserProvider|null createUserProvider(string|null $provider = null) + * @method static \Hypervel\Contracts\Auth\UserProvider|null createUserProvider(string|null $provider = null) * @method static string getDefaultUserProvider() * @method static bool check() * @method static bool guest() - * @method static \Hypervel\Auth\Contracts\Authenticatable|null user() + * @method static \Hypervel\Contracts\Auth\Authenticatable|null user() * @method static string|int|null id() * @method static bool validate(array $credentials = []) - * @method static void setUser(\Hypervel\Auth\Contracts\Authenticatable $user) + * @method static void setUser(\Hypervel\Contracts\Auth\Authenticatable $user) * @method static bool attempt(array $credentials = []) * @method static bool once(array $credentials = []) - * @method static void login(\Hypervel\Auth\Contracts\Authenticatable $user) - * @method static \Hypervel\Auth\Contracts\Authenticatable|bool loginUsingId(mixed $id) - * @method static \Hypervel\Auth\Contracts\Authenticatable|bool onceUsingId(mixed $id) + * @method static void login(\Hypervel\Contracts\Auth\Authenticatable $user) + * @method static \Hypervel\Contracts\Auth\Authenticatable|bool loginUsingId(mixed $id) + * @method static \Hypervel\Contracts\Auth\Authenticatable|bool onceUsingId(mixed $id) * @method static void logout() * * @see \Hypervel\Auth\AuthManager - * @see \Hypervel\Auth\Contracts\Guard - * @see \Hypervel\Auth\Contracts\StatefulGuard + * @see \Hypervel\Contracts\Auth\Guard + * @see \Hypervel\Contracts\Auth\StatefulGuard */ class Auth extends Facade { diff --git a/src/support/src/Facades/Broadcast.php b/src/support/src/Facades/Broadcast.php index ab2e17a05..a8d42321a 100644 --- a/src/support/src/Facades/Broadcast.php +++ b/src/support/src/Facades/Broadcast.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastingFactoryContract; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactoryContract; /** * @method static void routes(array $attributes = []) @@ -16,8 +16,8 @@ * @method static \Hypervel\Broadcasting\AnonymousEvent presence(string $channel) * @method static \Hypervel\Broadcasting\PendingBroadcast event(mixed $event = null) * @method static void queue(mixed $event) - * @method static \Hypervel\Broadcasting\Contracts\Broadcaster connection(string|null $driver = null) - * @method static \Hypervel\Broadcasting\Contracts\Broadcaster driver(string|null $name = null) + * @method static \Hypervel\Contracts\Broadcasting\Broadcaster connection(string|null $driver = null) + * @method static \Hypervel\Contracts\Broadcasting\Broadcaster driver(string|null $name = null) * @method static \Pusher\Pusher pusher(array $config) * @method static \Ably\AblyRest ably(array $config) * @method static string getDefaultDriver() @@ -35,7 +35,7 @@ * @method static \Hypervel\Broadcasting\BroadcastManager setPoolables(array $poolables) * @method static array|null resolveAuthenticatedUser(\Hyperf\HttpServer\Contract\RequestInterface $request) * @method static void resolveAuthenticatedUserUsing(\Closure $callback) - * @method static \Hypervel\Broadcasting\Broadcasters\Broadcaster channel(\Hypervel\Broadcasting\Contracts\HasBroadcastChannel|string $channel, callable|string $callback, array $options = []) + * @method static \Hypervel\Broadcasting\Broadcasters\Broadcaster channel(\Hypervel\Contracts\Broadcasting\HasBroadcastChannel|string $channel, callable|string $callback, array $options = []) * @method static \Hypervel\Support\Collection getChannels() * @method static void flushChannels() * @method static mixed auth(\Hyperf\HttpServer\Contract\RequestInterface $request) diff --git a/src/support/src/Facades/Bus.php b/src/support/src/Facades/Bus.php index 6325258a2..4d15f7e2d 100644 --- a/src/support/src/Facades/Bus.php +++ b/src/support/src/Facades/Bus.php @@ -4,21 +4,19 @@ namespace Hypervel\Support\Facades; -use Hypervel\Bus\Contracts\BatchRepository; -use Hypervel\Bus\Contracts\Dispatcher as BusDispatcherContract; use Hypervel\Bus\PendingChain; use Hypervel\Bus\PendingDispatch; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; use Hypervel\Support\Testing\Fakes\BusFake; -use function Hyperf\Tappable\tap; - /** * @method static mixed dispatch(mixed $command) * @method static mixed dispatchSync(mixed $command, mixed $handler = null) * @method static mixed dispatchNow(mixed $command, mixed $handler = null) * @method static \Hypervel\Bus\Batch|null findBatch(string $batchId) - * @method static \Hypervel\Bus\PendingBatch batch(array|\Hyperf\Collection\Collection|mixed $jobs) - * @method static \Hypervel\Bus\PendingChain chain(\Hyperf\Collection\Collection|array $jobs) + * @method static \Hypervel\Bus\PendingBatch batch(array|\Hypervel\Support\Collection|mixed $jobs) + * @method static \Hypervel\Bus\PendingChain chain(\Hypervel\Support\Collection|array $jobs) * @method static bool hasCommandHandler(mixed $command) * @method static bool|mixed getCommandHandler(mixed $command) * @method static mixed dispatchToQueue(mixed $command) @@ -44,10 +42,10 @@ * @method static void assertBatchCount(int $count) * @method static void assertNothingBatched() * @method static void assertNothingPlaced() - * @method static \Hyperf\Collection\Collection dispatched(string $command, callable|null $callback = null) - * @method static \Hyperf\Collection\Collection dispatchedSync(string $command, callable|null $callback = null) - * @method static \Hyperf\Collection\Collection dispatchedAfterResponse(string $command, callable|null $callback = null) - * @method static \Hyperf\Collection\Collection batched(callable $callback) + * @method static \Hypervel\Support\Collection dispatched(string $command, callable|null $callback = null) + * @method static \Hypervel\Support\Collection dispatchedSync(string $command, callable|null $callback = null) + * @method static \Hypervel\Support\Collection dispatchedAfterResponse(string $command, callable|null $callback = null) + * @method static \Hypervel\Support\Collection batched(callable $callback) * @method static bool hasDispatched(string $command) * @method static bool hasDispatchedSync(string $command) * @method static bool hasDispatchedAfterResponse(string $command) diff --git a/src/support/src/Facades/Cache.php b/src/support/src/Facades/Cache.php index 1f5868f6b..a4ace2201 100644 --- a/src/support/src/Facades/Cache.php +++ b/src/support/src/Facades/Cache.php @@ -4,12 +4,12 @@ namespace Hypervel\Support\Facades; -use Hypervel\Cache\Contracts\Factory; +use Hypervel\Contracts\Cache\Factory; /** - * @method static \Hypervel\Cache\Contracts\Repository store(string|null $name = null) - * @method static \Hypervel\Cache\Contracts\Repository driver(string|null $driver = null) - * @method static \Hypervel\Cache\Repository repository(\Hypervel\Cache\Contracts\Store $store, array $config = []) + * @method static \Hypervel\Contracts\Cache\Repository store(string|null $name = null) + * @method static \Hypervel\Contracts\Cache\Repository driver(string|null $driver = null) + * @method static \Hypervel\Cache\Repository repository(\Hypervel\Contracts\Cache\Store $store, array $config = []) * @method static void refreshEventDispatcher() * @method static string getDefaultDriver() * @method static void setDefaultDriver(string $name) @@ -27,7 +27,7 @@ * @method static mixed sear(string $key, \Closure $callback) * @method static mixed rememberForever(string $key, \Closure $callback) * @method static bool forget(string $key) - * @method static \Hypervel\Cache\Contracts\Store getStore() + * @method static \Hypervel\Contracts\Cache\Store getStore() * @method static mixed get(string $key, mixed $default = null) * @method static bool set(string $key, mixed $value, null|int|\DateInterval $ttl = null) * @method static bool delete(string $key) @@ -36,8 +36,8 @@ * @method static bool setMultiple(iterable $values, null|int|\DateInterval $ttl = null) * @method static bool deleteMultiple(iterable $keys) * @method static bool has(string $key) - * @method static \Hypervel\Cache\Contracts\Lock lock(string $name, int $seconds = 0, string|null $owner = null) - * @method static \Hypervel\Cache\Contracts\Lock restoreLock(string $name, string $owner) + * @method static \Hypervel\Contracts\Cache\Lock lock(string $name, int $seconds = 0, string|null $owner = null) + * @method static \Hypervel\Contracts\Cache\Lock restoreLock(string $name, string $owner) * @method static \Hypervel\Cache\TaggedCache tags(mixed $names) * @method static array many(array $keys) * @method static bool putMany(array $values, int $seconds) diff --git a/src/support/src/Facades/Config.php b/src/support/src/Facades/Config.php index 2c3ddd442..bc177558a 100644 --- a/src/support/src/Facades/Config.php +++ b/src/support/src/Facades/Config.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Config\Contracts\Repository as ConfigContract; +use Hypervel\Contracts\Config\Repository as ConfigContract; /** * @method static bool has(string $key) diff --git a/src/support/src/Facades/Cookie.php b/src/support/src/Facades/Cookie.php index 74d61308a..82f53f715 100644 --- a/src/support/src/Facades/Cookie.php +++ b/src/support/src/Facades/Cookie.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Cookie\Contracts\Cookie as CookieContract; +use Hypervel\Contracts\Cookie\Cookie as CookieContract; /** * @method static bool has(\UnitEnum|string $key) diff --git a/src/support/src/Facades/Crypt.php b/src/support/src/Facades/Crypt.php index a8f88a30b..0e92ff637 100644 --- a/src/support/src/Facades/Crypt.php +++ b/src/support/src/Facades/Crypt.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Encryption\Contracts\Encrypter as EncrypterContract; +use Hypervel\Contracts\Encryption\Encrypter as EncrypterContract; /** * @method static bool supported(string $key, string $cipher) diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index 93d748bea..bdc1fc120 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -4,13 +4,28 @@ namespace Hypervel\Support\Facades; -use Hyperf\DbConnection\Db as HyperfDb; +use Hypervel\Database\DatabaseManager; /** - * @method static void beforeExecuting(\Closure $closure) - * @method static \Hyperf\Database\Query\Builder table((\Hyperf\Database\Query\Expression|string) $table) - * @method static \Hyperf\Database\Query\Expression raw(mixed $value) + * @method static \Hypervel\Database\Connection connection(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Database\ConnectionInterface build(array $config) + * @method static \Hypervel\Database\ConnectionInterface connectUsing(string $name, array $config, bool $force = false) + * @method static void purge(\UnitEnum|string|null $name = null) + * @method static void disconnect(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Database\Connection reconnect(\UnitEnum|string|null $name = null) + * @method static mixed usingConnection(\UnitEnum|string $name, callable $callback) + * @method static string getDefaultConnection() + * @method static void setDefaultConnection(string $name) + * @method static string[] supportedDrivers() + * @method static string[] availableDrivers() + * @method static void extend(string $name, callable $resolver) + * @method static void forgetExtension(string $name) + * @method static array getConnections() + * @method static void setReconnector(callable $reconnector) + * @method static \Hypervel\Database\Query\Builder table(\Hypervel\Database\Query\Expression|string $table, ?string $as = null) + * @method static \Hypervel\Database\Query\Expression raw(mixed $value) * @method static mixed selectOne(string $query, array $bindings = [], bool $useReadPdo = true) + * @method static mixed scalar(string $query, array $bindings = [], bool $useReadPdo = true) * @method static array select(string $query, array $bindings = [], bool $useReadPdo = true) * @method static \Generator cursor(string $query, array $bindings = [], bool $useReadPdo = true) * @method static bool insert(string $query, array $bindings = []) @@ -22,18 +37,17 @@ * @method static array prepareBindings(array $bindings) * @method static mixed transaction(\Closure $callback, int $attempts = 1) * @method static void beginTransaction() - * @method static void rollBack() + * @method static void rollBack(?int $toLevel = null) * @method static void commit() * @method static int transactionLevel() * @method static array pretend(\Closure $callback) - * @method static \Hyperf\Database\ConnectionInterface connection(?string $pool = null) * - * @see \Hyperf\DbConnection\Db + * @see \Hypervel\Database\DatabaseManager */ class DB extends Facade { - protected static function getFacadeAccessor() + protected static function getFacadeAccessor(): string { - return HyperfDb::class; + return DatabaseManager::class; } } diff --git a/src/support/src/Facades/Event.php b/src/support/src/Facades/Event.php index 7012b79dc..15df3eaed 100644 --- a/src/support/src/Facades/Event.php +++ b/src/support/src/Facades/Event.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hyperf\Database\Model\Register; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Testing\Fakes\EventFake; use Psr\EventDispatcher\EventDispatcherInterface; @@ -30,7 +30,7 @@ * @method static void assertDispatchedTimes(string $event, int $times = 1) * @method static void assertNotDispatched(\Closure|string $event, callable|null $callback = null) * @method static void assertNothingDispatched() - * @method static \Hyperf\Collection\Collection dispatched(string $event, callable|null $callback = null) + * @method static \Hypervel\Support\Collection dispatched(string $event, callable|null $callback = null) * @method static bool hasDispatched(string $event) * @method static array dispatchedEvents() * @@ -46,7 +46,7 @@ public static function fake(array|string $eventsToFake = []): EventFake { static::swap($fake = new EventFake(static::getFacadeRoot(), $eventsToFake)); - Register::setEventDispatcher($fake); + Model::setEventDispatcher($fake); Cache::refreshEventDispatcher(); return $fake; @@ -76,7 +76,7 @@ public static function fakeFor(callable $callable, array $eventsToFake = []): mi return tap($callable(), function () use ($originalDispatcher) { static::swap($originalDispatcher); - Register::setEventDispatcher($originalDispatcher); + Model::setEventDispatcher($originalDispatcher); Cache::refreshEventDispatcher(); }); } @@ -93,7 +93,7 @@ public static function fakeExceptFor(callable $callable, array $eventsToAllow = return tap($callable(), function () use ($originalDispatcher) { static::swap($originalDispatcher); - Register::setEventDispatcher($originalDispatcher); + Model::setEventDispatcher($originalDispatcher); Cache::refreshEventDispatcher(); }); } diff --git a/src/support/src/Facades/Gate.php b/src/support/src/Facades/Gate.php index a71dadca3..6fcf16724 100644 --- a/src/support/src/Facades/Gate.php +++ b/src/support/src/Facades/Gate.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Auth\Contracts\Gate as GateContract; +use Hypervel\Contracts\Auth\Access\Gate as GateContract; /** * @method static bool has(\UnitEnum|array|string $ability) @@ -25,7 +25,7 @@ * @method static mixed raw(string $ability, mixed $arguments = []) * @method static mixed|void getPolicyFor(object|string $class) * @method static mixed resolvePolicy(string $class) - * @method static \Hypervel\Auth\Access\Gate forUser(\Hypervel\Auth\Contracts\Authenticatable|null $user) + * @method static \Hypervel\Auth\Access\Gate forUser(\Hypervel\Contracts\Auth\Authenticatable|null $user) * @method static array abilities() * @method static array policies() * @method static \Hypervel\Auth\Access\Gate defaultDenialResponse(\Hypervel\Auth\Access\Response $response) diff --git a/src/support/src/Facades/Hash.php b/src/support/src/Facades/Hash.php index e75a75ebe..24ef189c4 100644 --- a/src/support/src/Facades/Hash.php +++ b/src/support/src/Facades/Hash.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Hashing\Contracts\Hasher; +use Hypervel\Contracts\Hashing\Hasher; /** * @method static \Hypervel\Hashing\BcryptHasher createBcryptDriver() diff --git a/src/support/src/Facades/Lang.php b/src/support/src/Facades/Lang.php index e621dfca4..e684c59b9 100644 --- a/src/support/src/Facades/Lang.php +++ b/src/support/src/Facades/Lang.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; /** * @method static bool hasForLocale(string $key, string|null $locale = null) @@ -21,7 +21,7 @@ * @method static void determineLocalesUsing(callable $callback) * @method static \Hypervel\Translation\MessageSelector getSelector() * @method static void setSelector(\Hypervel\Translation\MessageSelector $selector) - * @method static \Hypervel\Translation\Contracts\Loader getLoader() + * @method static \Hypervel\Contracts\Translation\Loader getLoader() * @method static string locale() * @method static string getLocale() * @method static void setLocale(string $locale) diff --git a/src/support/src/Facades/Mail.php b/src/support/src/Facades/Mail.php index 3de7d2417..1ea56a371 100644 --- a/src/support/src/Facades/Mail.php +++ b/src/support/src/Facades/Mail.php @@ -4,12 +4,12 @@ namespace Hypervel\Support\Facades; -use Hypervel\Mail\Contracts\Factory as MailFactoryContract; +use Hypervel\Contracts\Mail\Factory as MailFactoryContract; use Hypervel\Support\Testing\Fakes\MailFake; /** - * @method static \Hypervel\Mail\Contracts\Mailer mailer(string|null $name = null) - * @method static \Hypervel\Mail\Contracts\Mailer driver(string|null $driver = null) + * @method static \Hypervel\Contracts\Mail\Mailer mailer(string|null $name = null) + * @method static \Hypervel\Contracts\Mail\Mailer driver(string|null $driver = null) * @method static \Symfony\Component\Mailer\Transport\TransportInterface createSymfonyTransport(array $config, string|null $poolName = null) * @method static string getDefaultDriver() * @method static void setDefaultDriver(string $name) @@ -27,8 +27,8 @@ * @method static \Hypervel\Mail\PendingMail to(mixed $users) * @method static \Hypervel\Mail\PendingMail bcc(mixed $users) * @method static \Hypervel\Mail\SentMessage|null raw(string $text, mixed $callback) - * @method static \Hypervel\Mail\SentMessage|null send(\Hypervel\Mail\Contracts\Mailable|array|string $view, array $data = [], \Closure|string|null $callback = null) - * @method static \Hypervel\Mail\SentMessage|null sendNow(\Hypervel\Mail\Contracts\Mailable|array|string $mailable, array $data = [], \Closure|string|null $callback = null) + * @method static \Hypervel\Mail\SentMessage|null send(\Hypervel\Contracts\Mail\Mailable|array|string $view, array $data = [], \Closure|string|null $callback = null) + * @method static \Hypervel\Mail\SentMessage|null sendNow(\Hypervel\Contracts\Mail\Mailable|array|string $mailable, array $data = [], \Closure|string|null $callback = null) * @method static void assertSent(\Closure|string $mailable, callable|array|string|int|null $callback = null) * @method static void assertNotOutgoing(\Closure|string $mailable, callable|null $callback = null) * @method static void assertNotSent(\Closure|string $mailable, callable|array|string|null $callback = null) @@ -40,13 +40,13 @@ * @method static void assertSentCount(int $count) * @method static void assertQueuedCount(int $count) * @method static void assertOutgoingCount(int $count) - * @method static \Hyperf\Collection\Collection sent(\Closure|string $mailable, callable|null $callback = null) + * @method static \Hypervel\Support\Collection sent(\Closure|string $mailable, callable|null $callback = null) * @method static bool hasSent(string $mailable) - * @method static \Hyperf\Collection\Collection queued(\Closure|string $mailable, callable|null $callback = null) + * @method static \Hypervel\Support\Collection queued(\Closure|string $mailable, callable|null $callback = null) * @method static bool hasQueued(string $mailable) * @method static \Hypervel\Mail\PendingMail cc(mixed $users) - * @method static mixed queue(\Hypervel\Mail\Contracts\Mailable|array|string $view, string|null $queue = null) - * @method static mixed later(\DateInterval|\DateTimeInterface|int $delay, \Hypervel\Mail\Contracts\Mailable|array|string $view, string|null $queue = null) + * @method static mixed queue(\Hypervel\Contracts\Mail\Mailable|array|string $view, string|null $queue = null) + * @method static mixed later(\DateInterval|\DateTimeInterface|int $delay, \Hypervel\Contracts\Mail\Mailable|array|string $view, string|null $queue = null) * * @see \Hypervel\Mail\MailManager * @see \Hypervel\Support\Testing\Fakes\MailFake diff --git a/src/support/src/Facades/Notification.php b/src/support/src/Facades/Notification.php index b517ce352..aaef60d76 100644 --- a/src/support/src/Facades/Notification.php +++ b/src/support/src/Facades/Notification.php @@ -4,12 +4,10 @@ namespace Hypervel\Support\Facades; +use Hypervel\Contracts\Notifications\Dispatcher as NotificationDispatcher; use Hypervel\Notifications\AnonymousNotifiable; -use Hypervel\Notifications\Contracts\Dispatcher as NotificationDispatcher; use Hypervel\Support\Testing\Fakes\NotificationFake; -use function Hyperf\Tappable\tap; - /** * @method static void send(mixed $notifiables, mixed $notification) * @method static void sendNow(mixed $notifiables, mixed $notification, array|null $channels = null) @@ -42,7 +40,7 @@ * @method static void assertNothingSentTo(mixed $notifiable) * @method static void assertSentTimes(string $notification, int $expectedCount) * @method static void assertCount(int $expectedCount) - * @method static \Hyperf\Collection\Collection sent(mixed $notifiable, string $notification, callable|null $callback = null) + * @method static \Hypervel\Support\Collection sent(mixed $notifiable, string $notification, callable|null $callback = null) * @method static bool hasSent(mixed $notifiable, string $notification) * @method static array sentNotifications() * @method static void macro(string $name, callable|object $macro) diff --git a/src/support/src/Facades/Process.php b/src/support/src/Facades/Process.php index 86cbb4dff..2f138c202 100644 --- a/src/support/src/Facades/Process.php +++ b/src/support/src/Facades/Process.php @@ -7,8 +7,6 @@ use Closure; use Hypervel\Process\Factory; -use function Hyperf\Tappable\tap; - /** * @method static \Hypervel\Process\PendingProcess command(array|string $command) * @method static \Hypervel\Process\PendingProcess path(string $path) diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index b3c22d71a..aeb88db19 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -5,12 +5,10 @@ namespace Hypervel\Support\Facades; use Hypervel\Context\ApplicationContext; -use Hypervel\Queue\Contracts\Factory as FactoryContract; +use Hypervel\Contracts\Queue\Factory as FactoryContract; use Hypervel\Queue\Worker; use Hypervel\Support\Testing\Fakes\QueueFake; -use function Hyperf\Tappable\tap; - /** * @method static void before(mixed $callback) * @method static void after(mixed $callback) @@ -19,7 +17,7 @@ * @method static void failing(mixed $callback) * @method static void stopping(mixed $callback) * @method static bool connected(string|null $name = null) - * @method static \Hypervel\Queue\Contracts\Queue connection(string|null $name = null) + * @method static \Hypervel\Contracts\Queue\Queue connection(string|null $name = null) * @method static void extend(string $driver, \Closure $resolver) * @method static void addConnector(string $driver, \Closure $resolver) * @method static string getDefaultDriver() @@ -44,9 +42,9 @@ * @method static mixed later(\DateInterval|\DateTimeInterface|int $delay, object|string $job, mixed $data = '', string|null $queue = null) * @method static mixed laterOn(string|null $queue, \DateInterval|\DateTimeInterface|int $delay, object|string $job, mixed $data = '') * @method static mixed bulk(array $jobs, mixed $data = '', string|null $queue = null) - * @method static \Hypervel\Queue\Contracts\Job|null pop(string|null $queue = null) + * @method static \Hypervel\Contracts\Queue\Job|null pop(string|null $queue = null) * @method static string getConnectionName() - * @method static \Hypervel\Queue\Contracts\Queue setConnectionName(string $name) + * @method static \Hypervel\Contracts\Queue\Queue setConnectionName(string $name) * @method static mixed getJobTries(mixed $job) * @method static mixed getJobBackoff(mixed $job) * @method static mixed getJobExpiration(mixed $job) @@ -65,7 +63,7 @@ * @method static void assertNotPushed(\Closure|string $job, callable|null $callback = null) * @method static void assertCount(int $expectedCount) * @method static void assertNothingPushed() - * @method static \Hyperf\Collection\Collection pushed(string $job, callable|null $callback = null) + * @method static \Hypervel\Support\Collection pushed(string $job, callable|null $callback = null) * @method static bool hasPushed(string $job) * @method static bool shouldFakeJob(object $job) * @method static array pushedJobs() diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index 74d66c663..128a7f326 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Http\Request as RequestContract; /** * @method static array allFiles() @@ -72,7 +72,7 @@ * @method static bool prefetch() * @method static bool isRange() * @method static bool hasSession() - * @method static \Hypervel\Session\Contracts\Session session() + * @method static \Hypervel\Contracts\Session\Session session() * @method static array validate(array $rules, array $messages = [], array $customAttributes = []) * @method static \Closure getUserResolver() * @method static \Hypervel\Http\Request setUserResolver(\Closure $callback) diff --git a/src/support/src/Facades/Response.php b/src/support/src/Facades/Response.php index 7f40442cc..914c52140 100644 --- a/src/support/src/Facades/Response.php +++ b/src/support/src/Facades/Response.php @@ -4,13 +4,13 @@ namespace Hypervel\Support\Facades; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Response as ResponseContract; /** * @method static \Psr\Http\Message\ResponseInterface make(mixed $content = '', int $status = 200, array $headers = []) * @method static \Psr\Http\Message\ResponseInterface noContent(int $status = 204, array $headers = []) * @method static \Psr\Http\Message\ResponseInterface view(string $view, array $data = [], int $status = 200, array $headers = []) - * @method static \Psr\Http\Message\ResponseInterface json(array|\Hyperf\Contract\Arrayable|\Hyperf\Contract\Jsonable $data, int $status = 200, array $headers = []) + * @method static \Psr\Http\Message\ResponseInterface json(array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Contracts\Support\Jsonable $data, int $status = 200, array $headers = []) * @method static \Psr\Http\Message\ResponseInterface file(string $path, array $headers = []) * @method static \Psr\Http\Message\ResponseInterface getPsr7Response() * @method static \Psr\Http\Message\ResponseInterface stream(callable $callback, array $headers = []) @@ -18,7 +18,7 @@ * @method static \Hypervel\Http\Response withRangeHeaders(int|null $fileSize = null) * @method static \Hypervel\Http\Response withoutRangeHeaders() * @method static bool shouldAppendRangeHeaders() - * @method static \Psr\Http\Message\ResponseInterface xml(array|\Hyperf\Contract\Arrayable|\Hyperf\Contract\Xmlable $data, string $root = 'root', string $charset = 'utf-8') + * @method static \Psr\Http\Message\ResponseInterface xml(array|\Hypervel\Contracts\Support\Arrayable|\Hyperf\Contract\Xmlable $data, string $root = 'root', string $charset = 'utf-8') * @method static \Psr\Http\Message\ResponseInterface html(string $html, string $charset = 'utf-8') * @method static \Psr\Http\Message\ResponseInterface raw(mixed|\Stringable $data, string $charset = 'utf-8') * @method static \Psr\Http\Message\ResponseInterface redirect(string $toUrl, int $status = 302, string $schema = 'http') diff --git a/src/support/src/Facades/Schedule.php b/src/support/src/Facades/Schedule.php index 5291083d6..bd3a86bc3 100644 --- a/src/support/src/Facades/Schedule.php +++ b/src/support/src/Facades/Schedule.php @@ -14,7 +14,7 @@ * @method static void group(\Closure $events) * @method static string compileArrayInput(string|int $key, array $value) * @method static bool serverShouldRun(\Hypervel\Console\Scheduling\Event $event, \DateTimeInterface $time) - * @method static \Hyperf\Collection\Collection dueEvents(\Hypervel\Foundation\Contracts\Application $app) + * @method static \Hypervel\Support\Collection dueEvents(\Hypervel\Contracts\Foundation\Application $app) * @method static array events() * @method static \Hypervel\Console\Scheduling\Schedule useCache(\UnitEnum|string|null $store) * @method static mixed macroCall(string $method, array $parameters) diff --git a/src/support/src/Facades/Schema.php b/src/support/src/Facades/Schema.php index 94156a127..22063af3d 100644 --- a/src/support/src/Facades/Schema.php +++ b/src/support/src/Facades/Schema.php @@ -32,11 +32,11 @@ * @method static bool enableForeignKeyConstraints() * @method static bool disableForeignKeyConstraints() * @method static array getForeignKeys(string $table) - * @method static \Hyperf\Database\Connection getConnection() - * @method static \Hyperf\Database\Schema\Builder setConnection(\Hyperf\Database\Connection $connection) + * @method static \Hypervel\Database\Connection getConnection() + * @method static \Hypervel\Database\Schema\Builder setConnection(\Hypervel\Database\Connection $connection) * @method static void blueprintResolver(\Closure $resolver) * - * @see \Hyperf\Database\Schema\Builder + * @see \Hypervel\Database\Schema\Builder */ class Schema extends Facade { diff --git a/src/support/src/Facades/Session.php b/src/support/src/Facades/Session.php index 29c57eb5b..e69bf63a6 100644 --- a/src/support/src/Facades/Session.php +++ b/src/support/src/Facades/Session.php @@ -4,10 +4,10 @@ namespace Hypervel\Support\Facades; -use Hypervel\Session\Contracts\Factory as SessionManagerContract; +use Hypervel\Contracts\Session\Factory as SessionManagerContract; /** - * @method static \Hypervel\Session\Contracts\Session store(string|null $name = null) + * @method static \Hypervel\Contracts\Session\Session store(string|null $name = null) * @method static bool shouldBlock() * @method static string|null blockDriver() * @method static int defaultRouteBlockLockSeconds() diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index 0e51566ec..440279159 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -4,8 +4,8 @@ namespace Hypervel\Support\Facades; -use Hyperf\Context\ApplicationContext; use Hyperf\Contract\ConfigInterface; +use Hypervel\Context\ApplicationContext; use Hypervel\Filesystem\Filesystem; use Hypervel\Filesystem\FilesystemManager; use UnitEnum; @@ -13,16 +13,16 @@ use function Hypervel\Support\enum_value; /** - * @method static \Hypervel\Filesystem\Contracts\Filesystem drive(\UnitEnum|string|null $name = null) - * @method static \Hypervel\Filesystem\Contracts\Filesystem disk(\UnitEnum|string|null $name = null) - * @method static \Hypervel\Filesystem\Contracts\Cloud cloud() - * @method static \Hypervel\Filesystem\Contracts\Filesystem build(array|string $config) - * @method static \Hypervel\Filesystem\Contracts\Filesystem createLocalDriver(array $config, string $name = 'local') - * @method static \Hypervel\Filesystem\Contracts\Filesystem createFtpDriver(array $config) - * @method static \Hypervel\Filesystem\Contracts\Filesystem createSftpDriver(array $config) - * @method static \Hypervel\Filesystem\Contracts\Cloud createS3Driver(array $config) - * @method static \Hypervel\Filesystem\Contracts\Cloud createGcsDriver(array $config) - * @method static \Hypervel\Filesystem\Contracts\Filesystem createScopedDriver(array $config) + * @method static \Hypervel\Contracts\Filesystem\Filesystem drive(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Contracts\Filesystem\Filesystem disk(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Contracts\Filesystem\Cloud cloud() + * @method static \Hypervel\Contracts\Filesystem\Filesystem build(array|string $config) + * @method static \Hypervel\Contracts\Filesystem\Filesystem createLocalDriver(array $config, string $name = 'local') + * @method static \Hypervel\Contracts\Filesystem\Filesystem createFtpDriver(array $config) + * @method static \Hypervel\Contracts\Filesystem\Filesystem createSftpDriver(array $config) + * @method static \Hypervel\Contracts\Filesystem\Cloud createS3Driver(array $config) + * @method static \Hypervel\Contracts\Filesystem\Cloud createGcsDriver(array $config) + * @method static \Hypervel\Contracts\Filesystem\Filesystem createScopedDriver(array $config) * @method static \Hypervel\Filesystem\FilesystemManager set(string $name, mixed $disk) * @method static string getDefaultDriver() * @method static string getDefaultCloudDriver() @@ -126,7 +126,7 @@ class Storage extends Facade /** * Replace the given disk with a local testing disk. * - * @return \Hypervel\Filesystem\Contracts\Filesystem + * @return \Hypervel\Contracts\Filesystem\Filesystem */ public static function fake(UnitEnum|string|null $disk = null, array $config = []) { @@ -150,7 +150,7 @@ public static function fake(UnitEnum|string|null $disk = null, array $config = [ /** * Replace the given disk with a persistent local testing disk. * - * @return \Hypervel\Filesystem\Contracts\Filesystem + * @return \Hypervel\Contracts\Filesystem\Filesystem */ public static function persistentFake(UnitEnum|string|null $disk = null, array $config = []) { diff --git a/src/support/src/Facades/URL.php b/src/support/src/Facades/URL.php index 86f5dcee0..b2bfa3820 100644 --- a/src/support/src/Facades/URL.php +++ b/src/support/src/Facades/URL.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; /** * @method static string route(string $name, array $parameters = [], bool $absolute = true, string $server = 'http') diff --git a/src/support/src/Facades/Validator.php b/src/support/src/Facades/Validator.php index a3e570305..b2acd1119 100644 --- a/src/support/src/Facades/Validator.php +++ b/src/support/src/Facades/Validator.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Facades; -use Hypervel\Validation\Contracts\Factory as FactoryContract; +use Hypervel\Contracts\Validation\Factory as FactoryContract; /** * @method static \Hypervel\Validation\Validator make(array $data, array $rules, array $messages = [], array $attributes = []) @@ -16,7 +16,7 @@ * @method static void includeUnvalidatedArrayKeys() * @method static void excludeUnvalidatedArrayKeys() * @method static void resolver(\Closure $resolver) - * @method static \Hypervel\Translation\Contracts\Translator getTranslator() + * @method static \Hypervel\Contracts\Translation\Translator getTranslator() * @method static \Hypervel\Validation\PresenceVerifierInterface getPresenceVerifier() * @method static void setPresenceVerifier(\Hypervel\Validation\PresenceVerifierInterface $presenceVerifier) * @method static \Psr\Container\ContainerInterface|null getContainer() @@ -65,7 +65,7 @@ * @method static string getException() * @method static \Hypervel\Validation\Validator setException(string|\Throwable $exception) * @method static \Hypervel\Validation\Validator ensureExponentWithinAllowedRangeUsing(\Closure $callback) - * @method static void setTranslator(\Hypervel\Translation\Contracts\Translator $translator) + * @method static void setTranslator(\Hypervel\Contracts\Translation\Translator $translator) * @method static string makeReplacements(string $message, string $attribute, string $rule, array $parameters) * @method static string getDisplayableAttribute(string $attribute) * @method static string getDisplayableValue(string $attribute, mixed $value) diff --git a/src/support/src/Facades/View.php b/src/support/src/Facades/View.php index bbe8ea9df..7c7e180f7 100644 --- a/src/support/src/Facades/View.php +++ b/src/support/src/Facades/View.php @@ -7,11 +7,11 @@ use Hyperf\ViewEngine\Contract\FactoryInterface; /** - * @method static \Hyperf\ViewEngine\Contract\ViewInterface file(string $path, array|\Hyperf\Contract\Arrayable $data = [], array $mergeData = []) - * @method static \Hyperf\ViewEngine\Contract\ViewInterface make(string $view, array|\Hyperf\Contract\Arrayable $data = [], array $mergeData = []) - * @method static \Hyperf\ViewEngine\Contract\ViewInterface first(array $views, \Hyperf\Contract\Arrayable|array $data = [], array $mergeData = []) - * @method static string renderWhen(bool $condition, string $view, \Hyperf\Contract\Arrayable|array $data = [], array $mergeData = []) - * @method static string renderUnless(bool $condition, string $view, \Hyperf\Contract\Arrayable|array $data = [], array $mergeData = []) + * @method static \Hyperf\ViewEngine\Contract\ViewInterface file(string $path, array|\Hypervel\Contracts\Support\Arrayable $data = [], array $mergeData = []) + * @method static \Hyperf\ViewEngine\Contract\ViewInterface make(string $view, array|\Hypervel\Contracts\Support\Arrayable $data = [], array $mergeData = []) + * @method static \Hyperf\ViewEngine\Contract\ViewInterface first(array $views, \Hypervel\Contracts\Support\Arrayable|array $data = [], array $mergeData = []) + * @method static string renderWhen(bool $condition, string $view, \Hypervel\Contracts\Support\Arrayable|array $data = [], array $mergeData = []) + * @method static string renderUnless(bool $condition, string $view, \Hypervel\Contracts\Support\Arrayable|array $data = [], array $mergeData = []) * @method static string renderEach(string $view, array $data, string $iterator, string $empty = 'raw|') * @method static bool exists(string $view) * @method static \Hyperf\ViewEngine\Contract\EngineInterface getEngineFromPath(string $path) diff --git a/src/support/src/Fluent.php b/src/support/src/Fluent.php index fd1b06886..543635167 100644 --- a/src/support/src/Fluent.php +++ b/src/support/src/Fluent.php @@ -4,8 +4,306 @@ namespace Hypervel\Support; -use Hyperf\Support\Fluent as BaseFluent; +use ArrayAccess; +use ArrayIterator; +use Closure; +use Hyperf\Conditionable\Conditionable; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Support\Traits\InteractsWithData; +use Hypervel\Support\Traits\Macroable; +use IteratorAggregate; +use JsonSerializable; +use Traversable; -class Fluent extends BaseFluent +/** + * @template TKey of array-key + * @template TValue + * + * @implements \Hypervel\Contracts\Support\Arrayable + * @implements ArrayAccess + */ +class Fluent implements Arrayable, ArrayAccess, IteratorAggregate, Jsonable, JsonSerializable { + use Conditionable, InteractsWithData, Macroable { + __call as macroCall; + } + + /** + * All of the attributes set on the fluent instance. + * + * @var array + */ + protected array $attributes = []; + + /** + * Create a new fluent instance. + * + * @param iterable $attributes + */ + public function __construct(iterable $attributes = []) + { + $this->fill($attributes); + } + + /** + * Create a new fluent instance. + * + * @param iterable $attributes + */ + public static function make(iterable $attributes = []): static + { + return new static($attributes); + } + + /** + * Get an attribute from the fluent instance using "dot" notation. + * + * @template TGetDefault + * + * @param (Closure(): TGetDefault)|TGetDefault $default + * @return TGetDefault|TValue + */ + public function get(?string $key = null, mixed $default = null): mixed + { + return data_get($this->attributes, $key, $default); + } + + /** + * Set an attribute on the fluent instance using "dot" notation. + */ + public function set(string $key, mixed $value): static + { + data_set($this->attributes, $key, $value); + + return $this; + } + + /** + * Fill the fluent instance with an array of attributes. + * + * @param iterable $attributes + */ + public function fill(iterable $attributes): static + { + foreach ($attributes as $key => $value) { + $this->attributes[$key] = $value; + } + + return $this; + } + + /** + * Get an attribute from the fluent instance. + */ + public function value(string $key, mixed $default = null): mixed + { + if (array_key_exists($key, $this->attributes)) { + return $this->attributes[$key]; + } + + return value($default); + } + + /** + * Get the value of the given key as a new Fluent instance. + */ + public function scope(string $key, mixed $default = null): static + { + return new static( + (array) $this->get($key, $default) + ); + } + + /** + * Get all of the attributes from the fluent instance. + */ + public function all(mixed $keys = null): array + { + $data = $this->data(); + + if (! $keys) { + return $data; + } + + $results = []; + + foreach (is_array($keys) ? $keys : func_get_args() as $key) { + Arr::set($results, $key, Arr::get($data, $key)); + } + + return $results; + } + + /** + * Get data from the fluent instance. + */ + protected function data(?string $key = null, mixed $default = null): mixed + { + return $this->get($key, $default); + } + + /** + * Get the attributes from the fluent instance. + * + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Convert the fluent instance to an array. + * + * @return array + */ + public function toArray(): array + { + return $this->attributes; + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Convert the fluent instance to JSON. + */ + public function toJson(int $options = 0): string + { + return json_encode($this->jsonSerialize(), $options); + } + + /** + * Convert the fluent instance to pretty print formatted JSON. + */ + public function toPrettyJson(int $options = 0): string + { + return $this->toJson(JSON_PRETTY_PRINT | $options); + } + + /** + * Determine if the fluent instance is empty. + */ + public function isEmpty(): bool + { + return empty($this->attributes); + } + + /** + * Determine if the fluent instance is not empty. + */ + public function isNotEmpty(): bool + { + return ! $this->isEmpty(); + } + + /** + * Determine if the given offset exists. + * + * @param TKey $offset + */ + public function offsetExists(mixed $offset): bool + { + return isset($this->attributes[$offset]); + } + + /** + * Get the value for a given offset. + * + * @param TKey $offset + * @return null|TValue + */ + public function offsetGet(mixed $offset): mixed + { + return $this->value($offset); + } + + /** + * Set the value at the given offset. + * + * @param TKey $offset + * @param TValue $value + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->attributes[$offset] = $value; + } + + /** + * Unset the value at the given offset. + * + * @param TKey $offset + */ + public function offsetUnset(mixed $offset): void + { + unset($this->attributes[$offset]); + } + + /** + * Get an iterator for the attributes. + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->attributes); + } + + /** + * Handle dynamic calls to the fluent instance to set attributes. + * + * @param array $parameters + */ + public function __call(string $method, array $parameters): mixed + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + $this->attributes[$method] = count($parameters) > 0 ? array_first($parameters) : true; + + return $this; + } + + /** + * Dynamically retrieve the value of an attribute. + * + * @return null|TValue + */ + public function __get(string $key): mixed + { + return $this->value($key); + } + + /** + * Dynamically set the value of an attribute. + */ + public function __set(string $key, mixed $value): void + { + $this->offsetSet($key, $value); + } + + /** + * Dynamically check if an attribute is set. + */ + public function __isset(string $key): bool + { + return $this->offsetExists($key); + } + + /** + * Dynamically unset an attribute. + */ + public function __unset(string $key): void + { + $this->offsetUnset($key); + } } diff --git a/src/support/src/Functions.php b/src/support/src/Functions.php index 1141400dd..27f57d8dc 100644 --- a/src/support/src/Functions.php +++ b/src/support/src/Functions.php @@ -4,57 +4,109 @@ namespace Hypervel\Support; -use BackedEnum; -use Closure; +use Carbon\CarbonInterface; +use Carbon\CarbonInterval; +use DateTimeZone; +use Hypervel\Support\Facades\Date; use Symfony\Component\Process\PhpExecutableFinder; use UnitEnum; /** - * Return the default value of the given value. - * @template TValue - * @template TReturn - * - * @param (Closure(TValue):TReturn)|TValue $value - * @return ($value is Closure ? TReturn : TValue) + * Determine the PHP Binary. */ -function value(mixed $value, ...$args) +function php_binary(): string { - return $value instanceof Closure ? $value(...$args) : $value; + return (new PhpExecutableFinder())->find(false) ?: 'php'; } /** - * Return a scalar value for the given value that might be an enum. - * - * @internal - * - * @template TValue - * @template TDefault + * Determine the proper Artisan executable. + */ +function artisan_binary(): string +{ + return defined('ARTISAN_BINARY') ? ARTISAN_BINARY : 'artisan'; +} + +// Time functions... + +/** + * Create a new Carbon instance for the current time. * - * @param TValue $value - * @param callable(TValue): TDefault|TDefault $default - * @return ($value is empty ? TDefault : mixed) + * @return \Hypervel\Support\Carbon */ -function enum_value($value, $default = null) +function now(DateTimeZone|UnitEnum|string|null $tz = null): CarbonInterface { - return match (true) { - $value instanceof BackedEnum => $value->value, - $value instanceof UnitEnum => $value->name, - default => $value ?? value($default), - }; + return Date::now(enum_value($tz)); } /** - * Determine the PHP Binary. + * Get the current date / time plus the given number of microseconds. */ -function php_binary(): string +function microseconds(int|float $microseconds): CarbonInterval { - return (new PhpExecutableFinder())->find(false) ?: 'php'; + return CarbonInterval::microseconds($microseconds); +} + +/** + * Get the current date / time plus the given number of milliseconds. + */ +function milliseconds(int|float $milliseconds): CarbonInterval +{ + return CarbonInterval::milliseconds($milliseconds); +} + +/** + * Get the current date / time plus the given number of seconds. + */ +function seconds(int|float $seconds): CarbonInterval +{ + return CarbonInterval::seconds($seconds); +} + +/** + * Get the current date / time plus the given number of minutes. + */ +function minutes(int|float $minutes): CarbonInterval +{ + return CarbonInterval::minutes($minutes); +} + +/** + * Get the current date / time plus the given number of hours. + */ +function hours(int|float $hours): CarbonInterval +{ + return CarbonInterval::hours($hours); +} + +/** + * Get the current date / time plus the given number of days. + */ +function days(int|float $days): CarbonInterval +{ + return CarbonInterval::days($days); +} + +/** + * Get the current date / time plus the given number of weeks. + */ +function weeks(int $weeks): CarbonInterval +{ + return CarbonInterval::weeks($weeks); +} + +/** + * Get the current date / time plus the given number of months. + */ +function months(int $months): CarbonInterval +{ + return CarbonInterval::months($months); } /** - * Gets the value of an environment variable. + * Get the current date / time plus the given number of years. */ -function env(string $key, mixed $default = null): mixed +function years(int $years): CarbonInterval { - return \Hyperf\Support\env($key, $default); + return CarbonInterval::years($years); } diff --git a/src/support/src/HigherOrderTapProxy.php b/src/support/src/HigherOrderTapProxy.php index a577244bc..d637562bf 100644 --- a/src/support/src/HigherOrderTapProxy.php +++ b/src/support/src/HigherOrderTapProxy.php @@ -4,8 +4,23 @@ namespace Hypervel\Support; -use Hyperf\Tappable\HigherOrderTapProxy as HyperfHigherOrderTapProxy; - -class HigherOrderTapProxy extends HyperfHigherOrderTapProxy +class HigherOrderTapProxy { + /** + * Create a new tap proxy instance. + */ + public function __construct( + public mixed $target, + ) { + } + + /** + * Dynamically pass method calls to the target. + */ + public function __call(string $method, array $parameters): mixed + { + $this->target->{$method}(...$parameters); + + return $this->target; + } } diff --git a/src/support/src/HigherOrderWhenProxy.php b/src/support/src/HigherOrderWhenProxy.php deleted file mode 100644 index f062e3653..000000000 --- a/src/support/src/HigherOrderWhenProxy.php +++ /dev/null @@ -1,11 +0,0 @@ -toJson(); + } + + if ($data instanceof Arrayable) { + $data = $data->toArray(); + } + + return json_encode($data, $flags | JSON_THROW_ON_ERROR, $depth); + } + + /** + * Decode a JSON string. + * + * @throws JsonException + */ + public static function decode(string $json, bool $assoc = true, int $depth = 512, int $flags = 0): mixed + { + return json_decode($json, $assoc, $depth, $flags | JSON_THROW_ON_ERROR); + } +} diff --git a/src/support/src/LazyCollection.php b/src/support/src/LazyCollection.php deleted file mode 100644 index f105e51fb..000000000 --- a/src/support/src/LazyCollection.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ -class LazyCollection extends BaseLazyCollection -{ - /** - * Chunk the collection into chunks with a callback. - * - * @phpstan-ignore-next-line - */ - public function chunkWhile(callable $callback): static - { - return new static(function () use ($callback) { - $iterator = $this->getIterator(); - - $chunk = new Collection(); - - if ($iterator->valid()) { - $chunk[$iterator->key()] = $iterator->current(); - - $iterator->next(); - } - - while ($iterator->valid()) { - if (! $callback($iterator->current(), $iterator->key(), $chunk)) { - yield new static($chunk); - - $chunk = new Collection(); - } - - $chunk[$iterator->key()] = $iterator->current(); - - $iterator->next(); - } - - if ($chunk->isNotEmpty()) { - yield new static($chunk); - } - }); - } - - /** - * Count the number of items in the collection by a field or using a callback. - * - * @param null|(callable(TValue, TKey): array-key)|string $countBy - * @return static - */ - public function countBy($countBy = null): static - { - $countBy = is_null($countBy) - ? $this->identity() - : $this->valueRetriever($countBy); - - return new static(function () use ($countBy) { - $counts = []; - - foreach ($this as $key => $value) { - $group = enum_value($countBy($value, $key)); - - if (empty($counts[$group])) { - $counts[$group] = 0; - } - - ++$counts[$group]; - } - - yield from $counts; - }); - } -} diff --git a/src/support/src/Manager.php b/src/support/src/Manager.php index 9851b30e3..c4d01e019 100644 --- a/src/support/src/Manager.php +++ b/src/support/src/Manager.php @@ -6,7 +6,6 @@ use Closure; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; use InvalidArgumentException; use Psr\Container\ContainerInterface; diff --git a/src/support/src/Mix.php b/src/support/src/Mix.php index 0787781c9..a8b19aa50 100644 --- a/src/support/src/Mix.php +++ b/src/support/src/Mix.php @@ -4,9 +4,8 @@ namespace Hypervel\Support; -use Hyperf\Context\ApplicationContext; -use Hyperf\Stringable\Str; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use RuntimeException; use function Hyperf\Config\config; diff --git a/src/support/src/Number.php b/src/support/src/Number.php index fe4c3657e..31e2eb0da 100644 --- a/src/support/src/Number.php +++ b/src/support/src/Number.php @@ -4,8 +4,8 @@ namespace Hypervel\Support; -use Hyperf\Context\Context; -use Hyperf\Macroable\Macroable; +use Hypervel\Context\Context; +use Hypervel\Support\Traits\Macroable; use NumberFormatter; use RuntimeException; @@ -217,7 +217,7 @@ public static function trim(float|int $number): float|int */ public static function withLocale(string $locale, callable $callback): mixed { - $previousLocale = static::$locale; + $previousLocale = static::defaultLocale(); static::useLocale($locale); @@ -229,7 +229,7 @@ public static function withLocale(string $locale, callable $callback): mixed */ public static function withCurrency(string $currency, callable $callback): mixed { - $previousCurrency = static::$currency; + $previousCurrency = static::defaultCurrency(); static::useCurrency($currency); @@ -249,7 +249,7 @@ public static function useLocale(string $locale): void */ public static function useCurrency(string $currency): void { - Context::get('__support.number.currency', $currency); + Context::set('__support.number.currency', $currency); } /** diff --git a/src/support/src/Once.php b/src/support/src/Once.php new file mode 100644 index 000000000..511f5106a --- /dev/null +++ b/src/support/src/Once.php @@ -0,0 +1,86 @@ +> $values + */ + protected function __construct(protected WeakMap $values) + { + } + + /** + * Create a new once instance. + */ + public static function instance(): static + { + return Context::getOrSet(self::INSTANCE_CONTEXT_KEY, fn () => new static(new WeakMap())); + } + + /** + * Get the value of the given onceable. + */ + public function value(Onceable $onceable): mixed + { + if (Context::get(self::ENABLED_CONTEXT_KEY, true) !== true) { + return call_user_func($onceable->callable); + } + + $object = $onceable->object ?: $this; + + $hash = $onceable->hash; + + if (! isset($this->values[$object])) { + $this->values[$object] = []; + } + + if (array_key_exists($hash, $this->values[$object])) { + return $this->values[$object][$hash]; + } + + return $this->values[$object][$hash] = call_user_func($onceable->callable); + } + + /** + * Re-enable the once instance if it was disabled. + */ + public static function enable(): void + { + Context::set(self::ENABLED_CONTEXT_KEY, true); + } + + /** + * Disable the once instance. + */ + public static function disable(): void + { + Context::set(self::ENABLED_CONTEXT_KEY, false); + } + + /** + * Flush the once instance. + */ + public static function flush(): void + { + Context::destroy(self::INSTANCE_CONTEXT_KEY); + } +} diff --git a/src/support/src/Onceable.php b/src/support/src/Onceable.php new file mode 100644 index 000000000..db0dd369f --- /dev/null +++ b/src/support/src/Onceable.php @@ -0,0 +1,92 @@ +> $trace + */ + public static function tryFromTrace(array $trace, callable $callable): ?static + { + if (! is_null($hash = static::hashFromTrace($trace, $callable))) { + $object = static::objectFromTrace($trace); + + return new static($hash, $object, $callable); + } + + return null; + } + + /** + * Computes the object of the onceable from the given trace, if any. + * + * @param array> $trace + * @return null|object + */ + protected static function objectFromTrace(array $trace) + { + return $trace[1]['object'] ?? null; + } + + /** + * Computes the hash of the onceable from the given trace. + * + * @param array> $trace + * @return null|string + */ + protected static function hashFromTrace(array $trace, callable $callable) + { + if (str_contains($trace[0]['file'] ?? '', 'eval()\'d code')) { + return null; + } + + $uses = array_map( + static function (mixed $argument) { + if ($argument instanceof HasOnceHash) { + return $argument->onceHash(); + } + + if (is_object($argument)) { + return spl_object_hash($argument); + } + + return $argument; + }, + $callable instanceof Closure ? (new ReflectionClosure($callable))->getClosureUsedVariables() : [], + ); + + $class = $callable instanceof Closure ? (new ReflectionClosure($callable))->getClosureCalledClass()?->getName() : null; + + $class ??= isset($trace[1]['class']) ? $trace[1]['class'] : null; + + return hash('xxh128', sprintf( + '%s@%s%s:%s (%s)', + $trace[0]['file'], + $class ? $class . '@' : '', + $trace[1]['function'], + $trace[0]['line'], + serialize($uses), + )); + } +} diff --git a/src/support/src/Optional.php b/src/support/src/Optional.php new file mode 100644 index 000000000..33cf6aa09 --- /dev/null +++ b/src/support/src/Optional.php @@ -0,0 +1,109 @@ +value = $value; + } + + /** + * Dynamically access a property on the underlying object. + */ + public function __get(string $key): mixed + { + if (is_object($this->value)) { + return $this->value->{$key} ?? null; + } + + return null; + } + + /** + * Dynamically check a property exists on the underlying object. + */ + public function __isset(mixed $name): bool + { + if ($this->value instanceof ArrayObject || is_array($this->value)) { + return isset($this->value[$name]); + } + + if (is_object($this->value)) { + return isset($this->value->{$name}); + } + + return false; + } + + /** + * Determine if an item exists at an offset. + */ + public function offsetExists(mixed $key): bool + { + return Arr::accessible($this->value) && Arr::exists($this->value, $key); + } + + /** + * Get an item at a given offset. + */ + public function offsetGet(mixed $key): mixed + { + return Arr::get($this->value, $key); + } + + /** + * Set the item at a given offset. + */ + public function offsetSet(mixed $key, mixed $value): void + { + if (Arr::accessible($this->value)) { + $this->value[$key] = $value; + } + } + + /** + * Unset the item at a given offset. + */ + public function offsetUnset(mixed $key): void + { + if (Arr::accessible($this->value)) { + unset($this->value[$key]); + } + } + + /** + * Dynamically pass a method to the underlying object. + */ + public function __call(string $method, array $parameters): mixed + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + if (is_object($this->value)) { + return $this->value->{$method}(...$parameters); + } + + return null; + } +} diff --git a/src/support/src/Pluralizer.php b/src/support/src/Pluralizer.php new file mode 100644 index 000000000..b25ffcc03 --- /dev/null +++ b/src/support/src/Pluralizer.php @@ -0,0 +1,105 @@ + + */ + public static array $uncountable = [ + 'recommended', + 'related', + ]; + + /** + * Get the plural form of an English word. + */ + public static function plural(string $value, array|Countable|int $count = 2): string + { + if (is_countable($count)) { + $count = count($count); + } + + if ((int) abs($count) === 1 || static::uncountable($value) || preg_match('/^(.*)[A-Za-z0-9\x{0080}-\x{FFFF}]$/u', $value) == 0) { + return $value; + } + + $plural = static::inflector()->pluralize($value); + + return static::matchCase($plural, $value); + } + + /** + * Get the singular form of an English word. + */ + public static function singular(string $value): string + { + $singular = static::inflector()->singularize($value); + + return static::matchCase($singular, $value); + } + + /** + * Determine if the given value is uncountable. + */ + protected static function uncountable(string $value): bool + { + return in_array(strtolower($value), static::$uncountable); + } + + /** + * Attempt to match the case on two strings. + */ + protected static function matchCase(string $value, string $comparison): string + { + $functions = ['mb_strtolower', 'mb_strtoupper', 'ucfirst', 'ucwords']; + + foreach ($functions as $function) { + if ($function($comparison) === $comparison) { + return $function($value); + } + } + + return $value; + } + + /** + * Get the inflector instance. + */ + public static function inflector(): Inflector + { + if (is_null(static::$inflector)) { + static::$inflector = InflectorFactory::createForLanguage(static::$language)->build(); + } + + return static::$inflector; + } + + /** + * Specify the language that should be used by the inflector. + */ + public static function useLanguage(string $language): void + { + static::$language = $language; + static::$inflector = null; + } +} diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index ae3659e55..1ca54c727 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -7,10 +7,10 @@ use Closure; use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\TranslatorLoaderInterface; -use Hyperf\Database\Migrations\Migrator; use Hyperf\ViewEngine\Compiler\BladeCompiler; use Hyperf\ViewEngine\Contract\FactoryInterface as ViewFactoryContract; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Database\Migrations\Migrator; use Hypervel\Router\RouteFileCollector; use Hypervel\Support\Facades\Artisan; diff --git a/src/support/src/Sleep.php b/src/support/src/Sleep.php index 322aa3878..d301a9d3a 100644 --- a/src/support/src/Sleep.php +++ b/src/support/src/Sleep.php @@ -7,14 +7,10 @@ use Carbon\CarbonInterval; use Closure; use DateInterval; -use Hyperf\Collection\Collection; -use Hyperf\Macroable\Macroable; +use Hypervel\Support\Traits\Macroable; use PHPUnit\Framework\Assert as PHPUnit; use RuntimeException; -use function Hyperf\Support\value; -use function Hyperf\Tappable\tap; - class Sleep { use Macroable; @@ -362,6 +358,7 @@ public static function assertSequence(array $sequence): void (new Collection($sequence)) ->zip(static::$sequence) + /* @phpstan-ignore argument.type (eachSpread signature can't express fixed-param callbacks) */ ->eachSpread(function (?Sleep $expected, CarbonInterval $actual) { if ($expected === null) { return; diff --git a/src/support/src/Str.php b/src/support/src/Str.php index a4f3a7145..2c7c38226 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -4,56 +4,397 @@ namespace Hypervel\Support; -use BackedEnum; -use Hyperf\Stringable\Str as BaseStr; +use Closure; +use Countable; +use DateTimeInterface; +use Hypervel\Support\Traits\Macroable; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; +use League\CommonMark\Extension\InlinesOnly\InlinesOnlyExtension; +use League\CommonMark\GithubFlavoredMarkdownConverter; +use League\CommonMark\MarkdownConverter; +use Ramsey\Uuid\Codec\TimestampFirstCombCodec; use Ramsey\Uuid\Exception\InvalidUuidStringException; +use Ramsey\Uuid\Generator\CombGenerator; use Ramsey\Uuid\Rfc4122\FieldsInterface; +use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidFactory; -use Stringable; +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\Uid\Ulid; +use Throwable; +use Traversable; +use voku\helper\ASCII; -class Str extends BaseStr +class Str { + use Macroable; + + /** + * The list of characters that are considered "invisible" in strings. + */ + public const INVISIBLE_CHARACTERS = '\x{0009}\x{0020}\x{00A0}\x{00AD}\x{034F}\x{061C}\x{115F}\x{1160}\x{17B4}\x{17B5}\x{180E}\x{2000}\x{2001}\x{2002}\x{2003}\x{2004}\x{2005}\x{2006}\x{2007}\x{2008}\x{2009}\x{200A}\x{200B}\x{200C}\x{200D}\x{200E}\x{200F}\x{202F}\x{205F}\x{2060}\x{2061}\x{2062}\x{2063}\x{2064}\x{2065}\x{206A}\x{206B}\x{206C}\x{206D}\x{206E}\x{206F}\x{3000}\x{2800}\x{3164}\x{FEFF}\x{FFA0}\x{1D159}\x{1D173}\x{1D174}\x{1D175}\x{1D176}\x{1D177}\x{1D178}\x{1D179}\x{1D17A}\x{E0020}'; + + /** + * The callback that should be used to generate UUIDs. + * + * @var null|(Closure(): \Ramsey\Uuid\UuidInterface) + */ + protected static ?Closure $uuidFactory = null; + /** - * Get a string from a BackedEnum, Stringable, or scalar value. + * The callback that should be used to generate ULIDs. * - * Useful for APIs that accept mixed identifier types, such as - * cache tags, session keys, or Sanctum token abilities. + * @var null|(Closure(): \Symfony\Component\Uid\Ulid) + */ + protected static ?Closure $ulidFactory = null; + + /** + * The callback that should be used to generate random strings. + * + * @var null|(Closure(int): string) + */ + protected static ?Closure $randomStringFactory = null; + + /** + * Get a new stringable object from the given string. + */ + public static function of(string $string): Stringable + { + return new Stringable($string); + } + + /** + * Return the remainder of a string after the first occurrence of a given value. + */ + public static function after(string $subject, string $search): string + { + return $search === '' ? $subject : array_reverse(explode($search, $subject, 2))[0]; + } + + /** + * Return the remainder of a string after the last occurrence of a given value. */ - public static function from(string|int|BackedEnum|Stringable $value): string + public static function afterLast(string $subject, string $search): string { - if ($value instanceof BackedEnum) { - return (string) $value->value; + if ($search === '') { + return $subject; } - if ($value instanceof Stringable) { - return (string) $value; + $position = strrpos($subject, $search); + + if ($position === false) { + return $subject; + } + + return substr($subject, $position + strlen($search)); + } + + /** + * Transliterate a UTF-8 value to ASCII. + */ + public static function ascii(string $value, string $language = 'en'): string + { + return ASCII::to_ascii($value, $language, replace_single_chars_only: false); + } + + /** + * Transliterate a string to its closest ASCII representation. + */ + public static function transliterate(string $string, ?string $unknown = '?', ?bool $strict = false): string + { + return ASCII::to_transliterate($string, $unknown, $strict); + } + + /** + * Get the portion of a string before the first occurrence of a given value. + */ + public static function before(string $subject, string $search): string + { + if ($search === '') { + return $subject; + } + + $result = strstr($subject, $search, true); + + return $result === false ? $subject : $result; + } + + /** + * Get the portion of a string before the last occurrence of a given value. + */ + public static function beforeLast(string $subject, string $search): string + { + if ($search === '') { + return $subject; + } + + $pos = mb_strrpos($subject, $search); + + if ($pos === false) { + return $subject; + } + + return static::substr($subject, 0, $pos); + } + + /** + * Get the portion of a string between two given values. + */ + public static function between(string $subject, string $from, string $to): string + { + if ($from === '' || $to === '') { + return $subject; + } + + return static::beforeLast(static::after($subject, $from), $to); + } + + /** + * Get the smallest possible portion of a string between two given values. + */ + public static function betweenFirst(string $subject, string $from, string $to): string + { + if ($from === '' || $to === '') { + return $subject; + } + + return static::before(static::after($subject, $from), $to); + } + + /** + * Convert a value to camel case. + */ + public static function camel(string $value): string + { + return lcfirst(static::studly($value)); + } + + /** + * Get the character at the specified index. + */ + public static function charAt(string $subject, int $index): string|false + { + $length = mb_strlen($subject); + + if ($index < 0 ? $index < -$length : $index > $length - 1) { + return false; + } + + return mb_substr($subject, $index, 1); + } + + /** + * Remove the given string(s) if it exists at the start of the haystack. + */ + public static function chopStart(string $subject, string|array $needle): string + { + foreach ((array) $needle as $n) { + if ($n !== '' && str_starts_with($subject, $n)) { + return mb_substr($subject, mb_strlen($n)); + } + } + + return $subject; + } + + /** + * Remove the given string(s) if it exists at the end of the haystack. + */ + public static function chopEnd(string $subject, string|array $needle): string + { + foreach ((array) $needle as $n) { + if ($n !== '' && str_ends_with($subject, $n)) { + return mb_substr($subject, 0, -mb_strlen($n)); + } + } + + return $subject; + } + + /** + * Determine if a given string contains a given substring. + * + * @param iterable|string $needles + */ + public static function contains(string $haystack, string|iterable $needles, bool $ignoreCase = false): bool + { + if ($ignoreCase) { + $haystack = mb_strtolower($haystack); + } + + if (! is_iterable($needles)) { + $needles = (array) $needles; + } + + foreach ($needles as $needle) { + if ($ignoreCase) { + $needle = mb_strtolower($needle); + } + + if ($needle !== '' && str_contains($haystack, $needle)) { + return true; + } + } + + return false; + } + + /** + * Determine if a given string contains all array values. + * + * @param iterable $needles + */ + public static function containsAll(string $haystack, iterable $needles, bool $ignoreCase = false): bool + { + foreach ($needles as $needle) { + if (! static::contains($haystack, $needle, $ignoreCase)) { + return false; + } + } + + return true; + } + + /** + * Determine if a given string doesn't contain a given substring. + * + * @param iterable|string $needles + */ + public static function doesntContain(string $haystack, string|iterable $needles, bool $ignoreCase = false): bool + { + return ! static::contains($haystack, $needles, $ignoreCase); + } + + /** + * Convert the case of a string. + */ + public static function convertCase(string $string, int $mode = MB_CASE_FOLD, ?string $encoding = 'UTF-8'): string + { + return mb_convert_case($string, $mode, $encoding); + } + + /** + * Replace consecutive instances of a given character with a single character in the given string. + * + * @param array|string $characters + */ + public static function deduplicate(string $string, array|string $characters = ' '): string + { + if (is_string($characters)) { + return preg_replace('/' . preg_quote($characters, '/') . '+/u', $characters, $string); } - return (string) $value; + return array_reduce( + $characters, + fn ($carry, $character) => preg_replace('/' . preg_quote($character, '/') . '+/u', $character, $carry), + $string + ); + } + + /** + * Determine if a given string ends with a given substring. + * + * @param iterable|string $needles + */ + public static function endsWith(string $haystack, string|iterable $needles): bool + { + if (! is_iterable($needles)) { + $needles = (array) $needles; + } + + foreach ($needles as $needle) { + if ((string) $needle !== '' && str_ends_with($haystack, $needle)) { + return true; + } + } + + return false; + } + + /** + * Determine if a given string doesn't end with a given substring. + * + * @param iterable|string $needles + */ + public static function doesntEndWith(string $haystack, string|iterable $needles): bool + { + return ! static::endsWith($haystack, $needles); } /** - * Get strings from an array of BackedEnums, Stringable objects, or scalar values. + * Extracts an excerpt from text that matches the first instance of a phrase. * - * @param array $values - * @return array + * @param array{radius?: float|int, omission?: string} $options + */ + public static function excerpt(string $text, string $phrase = '', array $options = []): ?string + { + $radius = $options['radius'] ?? 100; + $omission = $options['omission'] ?? '...'; + + preg_match('/^(.*?)(' . preg_quote($phrase, '/') . ')(.*)$/iu', $text, $matches); + + if (empty($matches)) { + return null; + } + + $start = ltrim($matches[1]); + + $start = Str::of(mb_substr($start, max(mb_strlen($start, 'UTF-8') - $radius, 0), $radius, 'UTF-8'))->ltrim()->unless( + fn ($startWithRadius) => $startWithRadius->exactly($start), + fn ($startWithRadius) => $startWithRadius->prepend($omission), + ); + + $end = rtrim($matches[3]); + + $end = Str::of(mb_substr($end, 0, $radius, 'UTF-8'))->rtrim()->unless( + fn ($endWithRadius) => $endWithRadius->exactly($end), + fn ($endWithRadius) => $endWithRadius->append($omission), + ); + + return $start->append($matches[2], $end->toString())->toString(); + } + + /** + * Cap a string with a single instance of a given value. */ - public static function fromAll(array $values): array + public static function finish(string $value, string $cap): string { - return array_map(self::from(...), $values); + $quoted = preg_quote($cap, '/'); + + return preg_replace('/(?:' . $quoted . ')+$/u', '', $value) . $cap; + } + + /** + * Wrap the string with the given strings. + */ + public static function wrap(string $value, string $before, ?string $after = null): string + { + return $before . $value . ($after ?? $before); + } + + /** + * Unwrap the string with the given strings. + */ + public static function unwrap(string $value, string $before, ?string $after = null): string + { + if (static::startsWith($value, $before)) { + $value = static::substr($value, static::length($before)); + } + + if (static::endsWith($value, $after ??= $before)) { + $value = static::substr($value, 0, -static::length($after)); + } + + return $value; } /** * Determine if a given string matches a given pattern. * * @param iterable|string $pattern - * @param string $value - * @param bool $ignoreCase */ - public static function is($pattern, $value, $ignoreCase = false): bool + public static function is(string|iterable $pattern, string $value, bool $ignoreCase = false): bool { - $value = (string) $value; - if (! is_iterable($pattern)) { $pattern = [$pattern]; } @@ -87,13 +428,73 @@ public static function is($pattern, $value, $ignoreCase = false): bool return false; } + /** + * Determine if a given string is 7 bit ASCII. + */ + public static function isAscii(string $value): bool + { + return ASCII::is_ascii($value); + } + + /** + * Determine if a given value is valid JSON. + */ + public static function isJson(mixed $value): bool + { + if (! is_string($value)) { + return false; + } + + return json_validate($value, 512); + } + + /** + * Determine if a given value is a valid URL. + * + * @param string[] $protocols + */ + public static function isUrl(mixed $value, array $protocols = []): bool + { + if (! is_string($value)) { + return false; + } + + $protocolList = empty($protocols) + ? 'aaa|aaas|about|acap|acct|acd|acr|adiumxtra|adt|afp|afs|aim|amss|android|appdata|apt|ark|attachment|aw|barion|beshare|bitcoin|bitcoincash|blob|bolo|browserext|calculator|callto|cap|cast|casts|chrome|chrome-extension|cid|coap|coap\+tcp|coap\+ws|coaps|coaps\+tcp|coaps\+ws|com-eventbrite-attendee|content|conti|crid|cvs|dab|data|dav|diaspora|dict|did|dis|dlna-playcontainer|dlna-playsingle|dns|dntp|dpp|drm|drop|dtn|dvb|ed2k|elsi|example|facetime|fax|feed|feedready|file|filesystem|finger|first-run-pen-experience|fish|fm|ftp|fuchsia-pkg|geo|gg|git|gizmoproject|go|gopher|graph|gtalk|h323|ham|hcap|hcp|http|https|hxxp|hxxps|hydrazone|iax|icap|icon|im|imap|info|iotdisco|ipn|ipp|ipps|irc|irc6|ircs|iris|iris\.beep|iris\.lwz|iris\.xpc|iris\.xpcs|isostore|itms|jabber|jar|jms|keyparc|lastfm|ldap|ldaps|leaptofrogans|lorawan|lvlt|magnet|mailserver|mailto|maps|market|message|mid|mms|modem|mongodb|moz|ms-access|ms-browser-extension|ms-calculator|ms-drive-to|ms-enrollment|ms-excel|ms-eyecontrolspeech|ms-gamebarservices|ms-gamingoverlay|ms-getoffice|ms-help|ms-infopath|ms-inputapp|ms-lockscreencomponent-config|ms-media-stream-id|ms-mixedrealitycapture|ms-mobileplans|ms-officeapp|ms-people|ms-project|ms-powerpoint|ms-publisher|ms-restoretabcompanion|ms-screenclip|ms-screensketch|ms-search|ms-search-repair|ms-secondary-screen-controller|ms-secondary-screen-setup|ms-settings|ms-settings-airplanemode|ms-settings-bluetooth|ms-settings-camera|ms-settings-cellular|ms-settings-cloudstorage|ms-settings-connectabledevices|ms-settings-displays-topology|ms-settings-emailandaccounts|ms-settings-language|ms-settings-location|ms-settings-lock|ms-settings-nfctransactions|ms-settings-notifications|ms-settings-power|ms-settings-privacy|ms-settings-proximity|ms-settings-screenrotation|ms-settings-wifi|ms-settings-workplace|ms-spd|ms-sttoverlay|ms-transit-to|ms-useractivityset|ms-virtualtouchpad|ms-visio|ms-walk-to|ms-whiteboard|ms-whiteboard-cmd|ms-word|msnim|msrp|msrps|mss|mtqp|mumble|mupdate|mvn|news|nfs|ni|nih|nntp|notes|ocf|oid|onenote|onenote-cmd|opaquelocktoken|openpgp4fpr|pack|palm|paparazzi|payto|pkcs11|platform|pop|pres|prospero|proxy|pwid|psyc|pttp|qb|query|redis|rediss|reload|res|resource|rmi|rsync|rtmfp|rtmp|rtsp|rtsps|rtspu|s3|secondlife|service|session|sftp|sgn|shttp|sieve|simpleledger|sip|sips|skype|smb|sms|smtp|snews|snmp|soap\.beep|soap\.beeps|soldat|spiffe|spotify|ssh|steam|stun|stuns|submit|svn|tag|teamspeak|tel|teliaeid|telnet|tftp|tg|things|thismessage|tip|tn3270|tool|ts3server|turn|turns|tv|udp|unreal|urn|ut2004|v-event|vemmi|ventrilo|videotex|vnc|view-source|wais|webcal|wpid|ws|wss|wtai|wyciwyg|xcon|xcon-userid|xfire|xmlrpc\.beep|xmlrpc\.beeps|xmpp|xri|ymsgr|z39\.50|z39\.50r|z39\.50s' + : implode('|', $protocols); + + /* + * This pattern is derived from Symfony\Component\Validator\Constraints\UrlValidator (5.0.7). + * + * (c) Fabien Potencier http://symfony.com + */ + $pattern = '~^ + (LARAVEL_PROTOCOLS):// # protocol + (((?:[\_\.\pL\pN-]|%[0-9A-Fa-f]{2})+:)?((?:[\_\.\pL\pN-]|%[0-9A-Fa-f]{2})+)@)? # basic auth + ( + ([\pL\pN\pS\-\_\.])+(\.?([\pL\pN]|xn\-\-[\pL\pN-]+)+\.?) # a domain name + | # or + \d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} # an IP address + | # or + \[ + (?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::)))) + \] # an IPv6 address + ) + (:[0-9]+)? # a port (optional) + (?:/ (?:[\pL\pN\-._\~!$&\'()*+,;=:@]|%[0-9A-Fa-f]{2})* )* # a path + (?:\? (?:[\pL\pN\-._\~!$&\'\[\]()*+,;=:@/?]|%[0-9A-Fa-f]{2})* )? # a query (optional) + (?:\# (?:[\pL\pN\-._\~!$&\'()*+,;=:@/?]|%[0-9A-Fa-f]{2})* )? # a fragment (optional) + $~ixu'; + + return preg_match(str_replace('LARAVEL_PROTOCOLS', $protocolList, $pattern), $value) > 0; + } + /** * Determine if a given value is a valid UUID. * - * @param mixed $value * @param null|'max'|'nil'|int<0, 8> $version */ - public static function isUuid($value, $version = null): bool + public static function isUuid(mixed $value, int|string|null $version = null): bool { if (! is_string($value)) { return false; @@ -122,10 +523,1182 @@ public static function isUuid($value, $version = null): bool } if ($version === 'max') { - /* @phpstan-ignore-next-line */ - return $fields->isMax(); + return $fields->isMax(); // @phpstan-ignore method.notFound (method exists on concrete class, not interface) } return $fields->getVersion() === $version; } + + /** + * Determine if a given value is a valid ULID. + */ + public static function isUlid(mixed $value): bool + { + if (! is_string($value)) { + return false; + } + + return Ulid::isValid($value); + } + + /** + * Convert a string to kebab case. + */ + public static function kebab(string $value): string + { + return static::snake($value, '-'); + } + + /** + * Return the length of the given string. + */ + public static function length(string $value, ?string $encoding = null): int + { + return mb_strlen($value, $encoding); + } + + /** + * Limit the number of characters in a string. + */ + public static function limit(string $value, int $limit = 100, string $end = '...', bool $preserveWords = false): string + { + if (mb_strwidth($value, 'UTF-8') <= $limit) { + return $value; + } + + if (! $preserveWords) { + return rtrim(mb_strimwidth($value, 0, $limit, '', 'UTF-8')) . $end; + } + + $value = trim(preg_replace('/[\n\r]+/', ' ', strip_tags($value))); + + $trimmed = rtrim(mb_strimwidth($value, 0, $limit, '', 'UTF-8')); + + if (mb_substr($value, $limit, 1, 'UTF-8') === ' ') { + return $trimmed . $end; + } + + return preg_replace('/(.*)\s.*/', '$1', $trimmed) . $end; + } + + /** + * Convert the given string to lower-case. + */ + public static function lower(string $value): string + { + return mb_strtolower($value, 'UTF-8'); + } + + /** + * Limit the number of words in a string. + */ + public static function words(string $value, int $words = 100, string $end = '...'): string + { + preg_match('/^\s*+(?:\S++\s*+){1,' . $words . '}/u', $value, $matches); + + if (! isset($matches[0]) || static::length($value) === static::length($matches[0])) { + return $value; + } + + return rtrim($matches[0]) . $end; + } + + /** + * Converts GitHub flavored Markdown into HTML. + * + * @param \League\CommonMark\Extension\ExtensionInterface[] $extensions + */ + public static function markdown(string $string, array $options = [], array $extensions = []): string + { + $converter = new GithubFlavoredMarkdownConverter($options); + + $environment = $converter->getEnvironment(); + + foreach ($extensions as $extension) { + $environment->addExtension($extension); + } + + return (string) $converter->convert($string); + } + + /** + * Converts inline Markdown into HTML. + * + * @param \League\CommonMark\Extension\ExtensionInterface[] $extensions + */ + public static function inlineMarkdown(string $string, array $options = [], array $extensions = []): string + { + $environment = new Environment($options); + + $environment->addExtension(new GithubFlavoredMarkdownExtension()); + $environment->addExtension(new InlinesOnlyExtension()); + + foreach ($extensions as $extension) { + $environment->addExtension($extension); + } + + $converter = new MarkdownConverter($environment); + + return (string) $converter->convert($string); + } + + /** + * Masks a portion of a string with a repeated character. + */ + public static function mask(string $string, string $character, int $index, ?int $length = null, string $encoding = 'UTF-8'): string + { + if ($character === '') { + return $string; + } + + $segment = mb_substr($string, $index, $length, $encoding); + + if ($segment === '') { + return $string; + } + + $strlen = mb_strlen($string, $encoding); + $startIndex = $index; + + if ($index < 0) { + $startIndex = $index < -$strlen ? 0 : $strlen + $index; + } + + $start = mb_substr($string, 0, $startIndex, $encoding); + $segmentLen = mb_strlen($segment, $encoding); + $end = mb_substr($string, $startIndex + $segmentLen); + + return $start . str_repeat(mb_substr($character, 0, 1, $encoding), $segmentLen) . $end; + } + + /** + * Get the string matching the given pattern. + */ + public static function match(string $pattern, string $subject): string + { + preg_match($pattern, $subject, $matches); + + if (! $matches) { + return ''; + } + + return $matches[1] ?? $matches[0]; + } + + /** + * Determine if a given string matches a given pattern. + * + * @param iterable|string $pattern + */ + public static function isMatch(string|iterable $pattern, string $value): bool + { + if (! is_iterable($pattern)) { + $pattern = [$pattern]; + } + + foreach ($pattern as $pattern) { + $pattern = (string) $pattern; + + if (preg_match($pattern, $value) === 1) { + return true; + } + } + + return false; + } + + /** + * Get the string matching the given pattern. + */ + public static function matchAll(string $pattern, string $subject): Collection + { + preg_match_all($pattern, $subject, $matches); + + if (empty($matches[0])) { + return new Collection(); + } + + return new Collection($matches[1] ?? $matches[0]); + } + + /** + * Remove all non-numeric characters from a string. + */ + public static function numbers(string $value): string + { + return preg_replace('/[^0-9]/', '', $value); + } + + /** + * Pad both sides of a string with another. + */ + public static function padBoth(string $value, int $length, string $pad = ' '): string + { + return mb_str_pad($value, $length, $pad, STR_PAD_BOTH); + } + + /** + * Pad the left side of a string with another. + */ + public static function padLeft(string $value, int $length, string $pad = ' '): string + { + return mb_str_pad($value, $length, $pad, STR_PAD_LEFT); + } + + /** + * Pad the right side of a string with another. + */ + public static function padRight(string $value, int $length, string $pad = ' '): string + { + return mb_str_pad($value, $length, $pad, STR_PAD_RIGHT); + } + + /** + * Parse a Class[@]method style callback into class and method. + * + * @return array + */ + public static function parseCallback(string $callback, ?string $default = null): array + { + if (static::contains($callback, "@anonymous\0")) { + if (static::substrCount($callback, '@') > 1) { + return [ + static::beforeLast($callback, '@'), + static::afterLast($callback, '@'), + ]; + } + + return [$callback, $default]; + } + + return static::contains($callback, '@') ? explode('@', $callback, 2) : [$callback, $default]; + } + + /** + * Get the plural form of an English word. + */ + public static function plural(string $value, int|array|Countable $count = 2, bool $prependCount = false): string + { + if (is_countable($count)) { + $count = count($count); + } + + return ($prependCount ? Number::format($count) . ' ' : '') . Pluralizer::plural($value, $count); + } + + /** + * Pluralize the last word of an English, studly caps case string. + */ + public static function pluralStudly(string $value, int|array|Countable $count = 2): string + { + $parts = preg_split('/(.)(?=[A-Z])/u', $value, -1, PREG_SPLIT_DELIM_CAPTURE); + + $lastWord = array_pop($parts); + + return implode('', $parts) . self::plural($lastWord, $count); + } + + /** + * Pluralize the last word of an English, Pascal caps case string. + */ + public static function pluralPascal(string $value, int|array|Countable $count = 2): string + { + return static::pluralStudly($value, $count); + } + + /** + * Generate a random, secure password. + */ + public static function password(int $length = 32, bool $letters = true, bool $numbers = true, bool $symbols = true, bool $spaces = false): string + { + $password = new Collection(); + + $options = (new Collection([ + 'letters' => $letters === true ? [ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', + 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', + 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + ] : null, + 'numbers' => $numbers === true ? [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + ] : null, + 'symbols' => $symbols === true ? [ + '~', '!', '#', '$', '%', '^', '&', '*', '(', ')', '-', + '_', '.', ',', '<', '>', '?', '/', '\\', '{', '}', '[', + ']', '|', ':', ';', + ] : null, + 'spaces' => $spaces === true ? [' '] : null, + ])) + ->filter() + ->each(fn ($c) => $password->push($c[random_int(0, count($c) - 1)])) + ->flatten(); + + $length = $length - $password->count(); + + return $password->merge($options->pipe( + fn ($c) => Collection::times($length, fn () => $c[random_int(0, $c->count() - 1)]) // @phpstan-ignore argument.type, return.type + ))->shuffle()->implode(''); + } + + /** + * Find the multi-byte safe position of the first occurrence of a given substring in a string. + */ + public static function position(string $haystack, string $needle, int $offset = 0, ?string $encoding = null): int|false + { + return mb_strpos($haystack, $needle, $offset, $encoding); + } + + /** + * Generate a more truly "random" alpha-numeric string. + */ + public static function random(int $length = 16): string + { + return (static::$randomStringFactory ?? function ($length) { + $string = ''; + + while (($len = strlen($string)) < $length) { + $size = $length - $len; + + $bytesSize = (int) ceil($size / 3) * 3; + + $bytes = random_bytes($bytesSize); + + $string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size); + } + + return $string; + })($length); + } + + /** + * Set the callable that will be used to generate random strings. + * + * @param null|(callable(int): string) $factory + */ + public static function createRandomStringsUsing(?callable $factory = null): void + { + static::$randomStringFactory = $factory; + } + + /** + * Set the sequence that will be used to generate random strings. + * + * @param string[] $sequence + * @param null|(callable(int): string) $whenMissing + */ + public static function createRandomStringsUsingSequence(array $sequence, ?callable $whenMissing = null): void + { + $next = 0; + + $whenMissing ??= function ($length) use (&$next) { + $factoryCache = static::$randomStringFactory; + + static::$randomStringFactory = null; + + $randomString = static::random($length); + + static::$randomStringFactory = $factoryCache; + + ++$next; + + return $randomString; + }; + + static::createRandomStringsUsing(function ($length) use (&$next, $sequence, $whenMissing) { + if (array_key_exists($next, $sequence)) { + return $sequence[$next++]; + } + + return $whenMissing($length); + }); + } + + /** + * Indicate that random strings should be created normally and not using a custom factory. + */ + public static function createRandomStringsNormally(): void + { + static::$randomStringFactory = null; + } + + /** + * Repeat the given string. + */ + public static function repeat(string $string, int $times): string + { + return str_repeat($string, $times); + } + + /** + * Replace a given value in the string sequentially with an array. + * + * @param iterable $replace + */ + public static function replaceArray(string $search, iterable $replace, string $subject): string + { + if ($replace instanceof Traversable) { + $replace = Arr::from($replace); + } + + $segments = explode($search, $subject); + + $result = array_shift($segments); + + foreach ($segments as $segment) { + $result .= self::toStringOr(array_shift($replace) ?? $search, $search) . $segment; + } + + return $result; + } + + /** + * Convert the given value to a string or return the given fallback on failure. + */ + private static function toStringOr(mixed $value, string $fallback): string + { + try { + return (string) $value; + } catch (Throwable) { // @phpstan-ignore catch.neverThrown (__toString can throw) + return $fallback; + } + } + + /** + * Replace the given value in the given string. + * + * @param iterable|string $search + * @param iterable|string $replace + * @param iterable|string $subject + */ + public static function replace(string|iterable $search, string|iterable $replace, string|iterable $subject, bool $caseSensitive = true): string|array + { + if ($search instanceof Traversable) { + $search = Arr::from($search); + } + + if ($replace instanceof Traversable) { + $replace = Arr::from($replace); + } + + if ($subject instanceof Traversable) { + $subject = Arr::from($subject); + } + + return $caseSensitive + ? str_replace($search, $replace, $subject) + : str_ireplace($search, $replace, $subject); + } + + /** + * Replace the first occurrence of a given value in the string. + */ + public static function replaceFirst(string $search, string $replace, string $subject): string + { + if ($search === '') { + return $subject; + } + + $position = strpos($subject, $search); + + if ($position !== false) { + return substr_replace($subject, $replace, $position, strlen($search)); + } + + return $subject; + } + + /** + * Replace the first occurrence of the given value if it appears at the start of the string. + */ + public static function replaceStart(string $search, string $replace, string $subject): string + { + if ($search === '') { + return $subject; + } + + if (static::startsWith($subject, $search)) { + return static::replaceFirst($search, $replace, $subject); + } + + return $subject; + } + + /** + * Replace the last occurrence of a given value in the string. + */ + public static function replaceLast(string $search, string $replace, string $subject): string + { + if ($search === '') { + return $subject; + } + + $position = strrpos($subject, $search); + + if ($position !== false) { + return substr_replace($subject, $replace, $position, strlen($search)); + } + + return $subject; + } + + /** + * Replace the last occurrence of a given value if it appears at the end of the string. + */ + public static function replaceEnd(string $search, string $replace, string $subject): string + { + if ($search === '') { + return $subject; + } + + if (static::endsWith($subject, $search)) { + return static::replaceLast($search, $replace, $subject); + } + + return $subject; + } + + /** + * Replace the patterns matching the given regular expression. + * + * @param string|string[] $pattern + * @param (Closure(array): string)|string|string[] $replace + * @param string|string[] $subject + */ + public static function replaceMatches(string|array $pattern, Closure|array|string $replace, string|array $subject, int $limit = -1): string|array|null + { + if ($replace instanceof Closure) { + return preg_replace_callback($pattern, $replace, $subject, $limit); + } + + return preg_replace($pattern, $replace, $subject, $limit); + } + + /** + * Remove any occurrence of the given string in the subject. + * + * @param iterable|string $search + */ + public static function remove(string|iterable $search, string $subject, bool $caseSensitive = true): string + { + if ($search instanceof Traversable) { + $search = Arr::from($search); + } + + return $caseSensitive + ? str_replace($search, '', $subject) + : str_ireplace($search, '', $subject); + } + + /** + * Reverse the given string. + */ + public static function reverse(string $value): string + { + return implode(array_reverse(mb_str_split($value))); + } + + /** + * Begin a string with a single instance of a given value. + */ + public static function start(string $value, string $prefix): string + { + $quoted = preg_quote($prefix, '/'); + + return $prefix . preg_replace('/^(?:' . $quoted . ')+/u', '', $value); + } + + /** + * Convert the given string to upper-case. + */ + public static function upper(string $value): string + { + return mb_strtoupper($value, 'UTF-8'); + } + + /** + * Convert the given string to proper case. + */ + public static function title(string $value): string + { + return mb_convert_case($value, MB_CASE_TITLE, 'UTF-8'); + } + + /** + * Convert the given string to proper case for each word. + */ + public static function headline(string $value): string + { + $parts = mb_split('\s+', $value); + + $parts = count($parts) > 1 + ? array_map(static::title(...), $parts) + : array_map(static::title(...), static::ucsplit(implode('_', $parts))); + + $collapsed = static::replace(['-', '_', ' '], '_', implode('_', $parts)); + + return implode(' ', array_filter(explode('_', $collapsed))); + } + + /** + * Convert the given string to APA-style title case. + * + * See: https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case + */ + public static function apa(string $value): string + { + if (trim($value) === '') { + return $value; + } + + $minorWords = [ + 'and', 'as', 'but', 'for', 'if', 'nor', 'or', 'so', 'yet', 'a', 'an', + 'the', 'at', 'by', 'in', 'of', 'off', 'on', 'per', 'to', 'up', 'via', + 'et', 'ou', 'un', 'une', 'la', 'le', 'les', 'de', 'du', 'des', 'par', 'à', + ]; + + $endPunctuation = ['.', '!', '?', ':', '—', ',']; + + $words = mb_split('\s+', $value); + $wordCount = count($words); + + for ($i = 0; $i < $wordCount; ++$i) { + $lowercaseWord = mb_strtolower($words[$i]); + + if (str_contains($lowercaseWord, '-')) { + $hyphenatedWords = explode('-', $lowercaseWord); + + $hyphenatedWords = array_map(function ($part) use ($minorWords) { + // @phpstan-ignore smallerOrEqual.alwaysTrue (defensive check) + return (in_array($part, $minorWords) && mb_strlen($part) <= 3) + ? $part + : mb_strtoupper(mb_substr($part, 0, 1)) . mb_substr($part, 1); + }, $hyphenatedWords); + + $words[$i] = implode('-', $hyphenatedWords); + } else { + if (in_array($lowercaseWord, $minorWords) + && mb_strlen($lowercaseWord) <= 3 // @phpstan-ignore smallerOrEqual.alwaysTrue + && ! ($i === 0 || in_array(mb_substr($words[$i - 1], -1), $endPunctuation))) { + $words[$i] = $lowercaseWord; + } else { + $words[$i] = mb_strtoupper(mb_substr($lowercaseWord, 0, 1)) . mb_substr($lowercaseWord, 1); + } + } + } + + return implode(' ', $words); + } + + /** + * Get the singular form of an English word. + */ + public static function singular(string $value): string + { + return Pluralizer::singular($value); + } + + /** + * Generate a URL friendly "slug" from a given string. + * + * @param array $dictionary + */ + public static function slug(string $title, string $separator = '-', ?string $language = 'en', array $dictionary = ['@' => 'at']): string + { + $title = $language ? static::ascii($title, $language) : $title; + + // Convert all dashes/underscores into separator + $flip = $separator === '-' ? '_' : '-'; + + $title = preg_replace('![' . preg_quote($flip) . ']+!u', $separator, $title); + + // Replace dictionary words + foreach ($dictionary as $key => $value) { + $dictionary[$key] = $separator . $value . $separator; + } + + $title = str_replace(array_keys($dictionary), array_values($dictionary), $title); + + // Remove all characters that are not the separator, letters, numbers, or whitespace + $title = preg_replace('![^' . preg_quote($separator) . '\pL\pN\s]+!u', '', static::lower($title)); + + // Replace all separator characters and whitespace by a single separator + $title = preg_replace('![' . preg_quote($separator) . '\s]+!u', $separator, $title); + + return trim($title, $separator); + } + + /** + * Convert a string to snake case. + */ + public static function snake(string $value, string $delimiter = '_'): string + { + if (! ctype_lower($value)) { + $value = preg_replace('/\s+/u', '', ucwords($value)); + + $value = static::lower(preg_replace('/(.)(?=[A-Z])/u', '$1' . $delimiter, $value)); + } + + return $value; + } + + /** + * Remove all whitespace from both ends of a string. + */ + public static function trim(string $value, ?string $charlist = null): string + { + if ($charlist === null) { + $trimDefaultCharacters = " \n\r\t\v\0"; + + return preg_replace('~^[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+|[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+$~u', '', $value) ?? trim($value); + } + + return trim($value, $charlist); + } + + /** + * Remove all whitespace from the beginning of a string. + */ + public static function ltrim(string $value, ?string $charlist = null): string + { + if ($charlist === null) { + $ltrimDefaultCharacters = " \n\r\t\v\0"; + + return preg_replace('~^[\s' . self::INVISIBLE_CHARACTERS . $ltrimDefaultCharacters . ']+~u', '', $value) ?? ltrim($value); + } + + return ltrim($value, $charlist); + } + + /** + * Remove all whitespace from the end of a string. + */ + public static function rtrim(string $value, ?string $charlist = null): string + { + if ($charlist === null) { + $rtrimDefaultCharacters = " \n\r\t\v\0"; + + return preg_replace('~[\s' . self::INVISIBLE_CHARACTERS . $rtrimDefaultCharacters . ']+$~u', '', $value) ?? rtrim($value); + } + + return rtrim($value, $charlist); + } + + /** + * Remove all "extra" blank space from the given string. + */ + public static function squish(string $value): string + { + return preg_replace('~(\s|\x{3164}|\x{1160})+~u', ' ', static::trim($value)); + } + + /** + * Determine if a given string starts with a given substring. + * + * @param iterable|string $needles + * @return ($needles is array{} ? false : ($haystack is non-empty-string ? bool : false)) + * + * @phpstan-assert-if-true =non-empty-string $haystack + */ + public static function startsWith(string $haystack, string|iterable $needles): bool + { + if (! is_iterable($needles)) { + $needles = [$needles]; + } + + foreach ($needles as $needle) { + if ((string) $needle !== '' && str_starts_with($haystack, $needle)) { + return true; + } + } + + return false; + } + + /** + * Determine if a given string doesn't start with a given substring. + * + * @param iterable|string $needles + * @return ($needles is array{} ? true : ($haystack is non-empty-string ? bool : true)) + * + * @phpstan-assert-if-false =non-empty-string $haystack + */ + public static function doesntStartWith(string $haystack, string|iterable $needles): bool + { + return ! static::startsWith($haystack, $needles); + } + + /** + * Convert a value to studly caps case. + * + * @return ($value is '' ? '' : string) + */ + public static function studly(string $value): string + { + $words = mb_split('\s+', static::replace(['-', '_'], ' ', $value)); + + $studlyWords = array_map(fn ($word) => static::ucfirst($word), $words); + + return implode($studlyWords); + } + + /** + * Convert a value to Pascal case. + * + * @return ($value is '' ? '' : string) + */ + public static function pascal(string $value): string + { + return static::studly($value); + } + + /** + * Returns the portion of the string specified by the start and length parameters. + */ + public static function substr(string $string, int $start, ?int $length = null, string $encoding = 'UTF-8'): string + { + return mb_substr($string, $start, $length, $encoding); + } + + /** + * Returns the number of substring occurrences. + */ + public static function substrCount(string $haystack, string $needle, int $offset = 0, ?int $length = null): int + { + if (! is_null($length)) { + return substr_count($haystack, $needle, $offset, $length); + } + + return substr_count($haystack, $needle, $offset); + } + + /** + * Replace text within a portion of a string. + * + * @param string|string[] $string + * @param string|string[] $replace + * @param int|int[] $offset + * @param null|int|int[] $length + * @return string|string[] + */ + public static function substrReplace(string|array $string, string|array $replace, int|array $offset = 0, int|array|null $length = null): string|array + { + if ($length === null) { + $length = static::length($string); + } + + return mb_substr($string, 0, $offset) + . $replace + . mb_substr($string, $offset + $length); + } + + /** + * Swap multiple keywords in a string with other keywords. + * + * @param array $map + */ + public static function swap(array $map, string $subject): string + { + return strtr($subject, $map); + } + + /** + * Take the first or last {$limit} characters of a string. + */ + public static function take(string $string, int $limit): string + { + if ($limit < 0) { + return static::substr($string, $limit); + } + + return static::substr($string, 0, $limit); + } + + /** + * Convert the given string to Base64 encoding. + * + * @return ($string is '' ? '' : string) + */ + public static function toBase64(string $string): string + { + return base64_encode($string); + } + + /** + * Decode the given Base64 encoded string. + * + * @return ($strict is true ? ($string is '' ? '' : false|string) : ($string is '' ? '' : string)) + */ + public static function fromBase64(string $string, bool $strict = false): string|false + { + return base64_decode($string, $strict); + } + + /** + * Make a string's first character lowercase. + * + * @return ($string is '' ? '' : non-empty-string) + */ + public static function lcfirst(string $string): string + { + return static::lower(static::substr($string, 0, 1)) . static::substr($string, 1); + } + + /** + * Make a string's first character uppercase. + * + * @return ($string is '' ? '' : non-empty-string) + */ + public static function ucfirst(string $string): string + { + return static::upper(static::substr($string, 0, 1)) . static::substr($string, 1); + } + + /** + * Capitalize the first character of each word in a string. + * + * @return ($string is '' ? '' : non-empty-string) + */ + public static function ucwords(string $string, string $separators = " \t\r\n\f\v"): string + { + $pattern = '/(^|[' . preg_quote($separators, '/') . '])(\p{Ll})/u'; + + return preg_replace_callback($pattern, function ($matches) { + return $matches[1] . mb_strtoupper($matches[2]); + }, $string); + } + + /** + * Split a string into pieces by uppercase characters. + * + * @return ($string is '' ? array{} : string[]) + */ + public static function ucsplit(string $string): array + { + return preg_split('/(?=\p{Lu})/u', $string, -1, PREG_SPLIT_NO_EMPTY); + } + + /** + * Get the number of words a string contains. + * + * @return non-negative-int + */ + public static function wordCount(string $string, ?string $characters = null): int + { + return str_word_count($string, 0, $characters); + } + + /** + * Wrap a string to a given number of characters. + */ + public static function wordWrap(string $string, int $characters = 75, string $break = "\n", bool $cutLongWords = false): string + { + return wordwrap($string, $characters, $break, $cutLongWords); + } + + /** + * Generate a UUID (version 4). + */ + public static function uuid(): UuidInterface + { + return static::$uuidFactory + ? call_user_func(static::$uuidFactory) + : Uuid::uuid4(); + } + + /** + * Generate a UUID (version 7). + */ + public static function uuid7(?DateTimeInterface $time = null): UuidInterface|string + { + return static::$uuidFactory + ? call_user_func(static::$uuidFactory) + : Uuid::uuid7($time); + } + + /** + * Generate a time-ordered UUID. + */ + public static function orderedUuid(): UuidInterface + { + if (static::$uuidFactory) { + return call_user_func(static::$uuidFactory); + } + + $factory = new UuidFactory(); + + $factory->setRandomGenerator(new CombGenerator( + $factory->getRandomGenerator(), + $factory->getNumberConverter() + )); + + $factory->setCodec(new TimestampFirstCombCodec( + $factory->getUuidBuilder() + )); + + return $factory->uuid4(); + } + + /** + * Set the callable that will be used to generate UUIDs. + * + * @param null|(callable(): UuidInterface) $factory + */ + public static function createUuidsUsing(?callable $factory = null): void + { + static::$uuidFactory = $factory; + } + + /** + * Set the sequence that will be used to generate UUIDs. + * + * @param UuidInterface[] $sequence + * @param null|(callable(): UuidInterface) $whenMissing + */ + public static function createUuidsUsingSequence(array $sequence, ?callable $whenMissing = null): void + { + $next = 0; + + $whenMissing ??= function () use (&$next) { + $factoryCache = static::$uuidFactory; + + static::$uuidFactory = null; + + $uuid = static::uuid(); + + static::$uuidFactory = $factoryCache; + + ++$next; + + return $uuid; + }; + + static::createUuidsUsing(function () use (&$next, $sequence, $whenMissing) { + if (array_key_exists($next, $sequence)) { + return $sequence[$next++]; + } + + return $whenMissing(); + }); + } + + /** + * Always return the same UUID when generating new UUIDs. + * + * @param null|(Closure(UuidInterface): mixed) $callback + */ + public static function freezeUuids(?Closure $callback = null): UuidInterface + { + $uuid = Str::uuid(); + + Str::createUuidsUsing(fn () => $uuid); + + if ($callback !== null) { + try { + $callback($uuid); + } finally { + Str::createUuidsNormally(); + } + } + + return $uuid; + } + + /** + * Indicate that UUIDs should be created normally and not using a custom factory. + */ + public static function createUuidsNormally(): void + { + static::$uuidFactory = null; + } + + /** + * Generate a ULID. + */ + public static function ulid(?DateTimeInterface $time = null): Ulid + { + if (static::$ulidFactory) { + return call_user_func(static::$ulidFactory); + } + + if ($time === null) { + return new Ulid(); + } + + return new Ulid(Ulid::generate($time)); + } + + /** + * Indicate that ULIDs should be created normally and not using a custom factory. + */ + public static function createUlidsNormally(): void + { + static::$ulidFactory = null; + } + + /** + * Set the callable that will be used to generate ULIDs. + * + * @param null|(callable(): Ulid) $factory + */ + public static function createUlidsUsing(?callable $factory = null): void + { + static::$ulidFactory = $factory; + } + + /** + * Set the sequence that will be used to generate ULIDs. + * + * @param Ulid[] $sequence + * @param null|(callable(): Ulid) $whenMissing + */ + public static function createUlidsUsingSequence(array $sequence, ?callable $whenMissing = null): void + { + $next = 0; + + $whenMissing ??= function () use (&$next) { + $factoryCache = static::$ulidFactory; + + static::$ulidFactory = null; + + $ulid = static::ulid(); + + static::$ulidFactory = $factoryCache; + + ++$next; + + return $ulid; + }; + + static::createUlidsUsing(function () use (&$next, $sequence, $whenMissing) { + if (array_key_exists($next, $sequence)) { + return $sequence[$next++]; + } + + return $whenMissing(); + }); + } + + /** + * Always return the same ULID when generating new ULIDs. + * + * @param null|(Closure(Ulid): mixed) $callback + */ + public static function freezeUlids(?Closure $callback = null): Ulid + { + $ulid = Str::ulid(); + + Str::createUlidsUsing(fn () => $ulid); + + if ($callback !== null) { + try { + $callback($ulid); + } finally { + Str::createUlidsNormally(); + } + } + + return $ulid; + } } diff --git a/src/support/src/StrCache.php b/src/support/src/StrCache.php index b6c5290ce..4523db976 100644 --- a/src/support/src/StrCache.php +++ b/src/support/src/StrCache.php @@ -4,8 +4,209 @@ namespace Hypervel\Support; -use Hyperf\Stringable\StrCache as BaseStrCache; - -class StrCache extends BaseStrCache +/** + * Cached string transformations for known-finite inputs. + * + * Use this class for framework internals where input strings come from + * a finite set (class names, attribute names, column names, etc.). + * + * For arbitrary or user-provided input, use Str methods directly + * to avoid unbounded cache growth in long-running Swoole workers. + */ +class StrCache { + /** + * The cache of snake-cased words. + * + * @var array> + */ + protected static array $snakeCache = []; + + /** + * The cache of camel-cased words. + * + * @var array + */ + protected static array $camelCache = []; + + /** + * The cache of studly-cased words. + * + * @var array + */ + protected static array $studlyCache = []; + + /** + * The cache of plural words. + * + * @var array + */ + protected static array $pluralCache = []; + + /** + * The cache of singular words. + * + * @var array + */ + protected static array $singularCache = []; + + /** + * The cache of plural studly words. + * + * @var array + */ + protected static array $pluralStudlyCache = []; + + /** + * Convert a string to snake case (cached). + */ + public static function snake(string $value, string $delimiter = '_'): string + { + if (isset(static::$snakeCache[$value][$delimiter])) { + return static::$snakeCache[$value][$delimiter]; + } + + return static::$snakeCache[$value][$delimiter] = Str::snake($value, $delimiter); + } + + /** + * Convert a value to camel case (cached). + */ + public static function camel(string $value): string + { + if (isset(static::$camelCache[$value])) { + return static::$camelCache[$value]; + } + + return static::$camelCache[$value] = Str::camel($value); + } + + /** + * Convert a value to studly case (cached). + */ + public static function studly(string $value): string + { + if (isset(static::$studlyCache[$value])) { + return static::$studlyCache[$value]; + } + + return static::$studlyCache[$value] = Str::studly($value); + } + + /** + * Get the plural form of a word (cached). + * + * Best for finite inputs like class names, not user input. + */ + public static function plural(string $value, int|array $count = 2): string + { + // Only cache the common case (count = 2, which gives plural form) + if ($count === 2 && isset(static::$pluralCache[$value])) { + return static::$pluralCache[$value]; + } + + $result = Str::plural($value, $count); + + if ($count === 2) { + static::$pluralCache[$value] = $result; + } + + return $result; + } + + /** + * Get the singular form of a word (cached). + * + * Best for finite inputs like class names, not user input. + */ + public static function singular(string $value): string + { + if (isset(static::$singularCache[$value])) { + return static::$singularCache[$value]; + } + + return static::$singularCache[$value] = Str::singular($value); + } + + /** + * Pluralize the last word of a studly caps string (cached). + * + * Best for finite inputs like class names, not user input. + */ + public static function pluralStudly(string $value, int|array $count = 2): string + { + // Only cache the common case (count = 2, which gives plural form) + if ($count === 2 && isset(static::$pluralStudlyCache[$value])) { + return static::$pluralStudlyCache[$value]; + } + + $result = Str::pluralStudly($value, $count); + + if ($count === 2) { + static::$pluralStudlyCache[$value] = $result; + } + + return $result; + } + + /** + * Flush all caches. + */ + public static function flush(): void + { + static::$snakeCache = []; + static::$camelCache = []; + static::$studlyCache = []; + static::$pluralCache = []; + static::$singularCache = []; + static::$pluralStudlyCache = []; + } + + /** + * Flush the snake cache. + */ + public static function flushSnake(): void + { + static::$snakeCache = []; + } + + /** + * Flush the camel cache. + */ + public static function flushCamel(): void + { + static::$camelCache = []; + } + + /** + * Flush the studly cache. + */ + public static function flushStudly(): void + { + static::$studlyCache = []; + } + + /** + * Flush the plural cache. + */ + public static function flushPlural(): void + { + static::$pluralCache = []; + } + + /** + * Flush the singular cache. + */ + public static function flushSingular(): void + { + static::$singularCache = []; + } + + /** + * Flush the plural studly cache. + */ + public static function flushPluralStudly(): void + { + static::$pluralStudlyCache = []; + } } diff --git a/src/support/src/Stringable.php b/src/support/src/Stringable.php index 02a1803bb..606d6b654 100644 --- a/src/support/src/Stringable.php +++ b/src/support/src/Stringable.php @@ -4,8 +4,1231 @@ namespace Hypervel\Support; -use Hyperf\Stringable\Stringable as BaseStringable; +use ArrayAccess; +use Closure; +use Countable; +use Hypervel\Support\Facades\Date; +use Hypervel\Support\Traits\Conditionable; +use Hypervel\Support\Traits\Dumpable; +use Hypervel\Support\Traits\Macroable; +use Hypervel\Support\Traits\Tappable; +use JsonSerializable; +use RuntimeException; +use Stringable as BaseStringable; -class Stringable extends BaseStringable +class Stringable implements JsonSerializable, ArrayAccess, BaseStringable { + use Conditionable; + use Dumpable; + use Macroable; + use Tappable; + + /** + * The underlying string value. + */ + protected string $value; + + /** + * Create a new instance of the class. + */ + public function __construct(string|\Stringable $value = '') + { + $this->value = (string) $value; + } + + /** + * Return the remainder of a string after the first occurrence of a given value. + */ + public function after(string $search): static + { + return new static(Str::after($this->value, $search)); + } + + /** + * Return the remainder of a string after the last occurrence of a given value. + */ + public function afterLast(string $search): static + { + return new static(Str::afterLast($this->value, $search)); + } + + /** + * Append the given values to the string. + */ + public function append(string ...$values): static + { + return new static($this->value . implode('', $values)); + } + + /** + * Append a new line to the string. + */ + public function newLine(int $count = 1): static + { + return $this->append(str_repeat(PHP_EOL, $count)); + } + + /** + * Transliterate a UTF-8 value to ASCII. + */ + public function ascii(string $language = 'en'): static + { + return new static(Str::ascii($this->value, $language)); + } + + /** + * Get the trailing name component of the path. + */ + public function basename(string $suffix = ''): static + { + return new static(basename($this->value, $suffix)); + } + + /** + * Get the character at the specified index. + */ + public function charAt(int $index): string|false + { + return Str::charAt($this->value, $index); + } + + /** + * Remove the given string if it exists at the start of the current string. + */ + public function chopStart(string|array $needle): static + { + return new static(Str::chopStart($this->value, $needle)); + } + + /** + * Remove the given string if it exists at the end of the current string. + */ + public function chopEnd(string|array $needle): static + { + return new static(Str::chopEnd($this->value, $needle)); + } + + /** + * Get the basename of the class path. + */ + public function classBasename(): static + { + return new static(class_basename($this->value)); + } + + /** + * Get the portion of a string before the first occurrence of a given value. + */ + public function before(string $search): static + { + return new static(Str::before($this->value, $search)); + } + + /** + * Get the portion of a string before the last occurrence of a given value. + */ + public function beforeLast(string $search): static + { + return new static(Str::beforeLast($this->value, $search)); + } + + /** + * Get the portion of a string between two given values. + */ + public function between(string $from, string $to): static + { + return new static(Str::between($this->value, $from, $to)); + } + + /** + * Get the smallest possible portion of a string between two given values. + */ + public function betweenFirst(string $from, string $to): static + { + return new static(Str::betweenFirst($this->value, $from, $to)); + } + + /** + * Convert a value to camel case. + */ + public function camel(): static + { + return new static(Str::camel($this->value)); + } + + /** + * Determine if a given string contains a given substring. + * + * @param iterable|string $needles + */ + public function contains(string|iterable $needles, bool $ignoreCase = false): bool + { + return Str::contains($this->value, $needles, $ignoreCase); + } + + /** + * Determine if a given string contains all array values. + * + * @param iterable $needles + */ + public function containsAll(iterable $needles, bool $ignoreCase = false): bool + { + return Str::containsAll($this->value, $needles, $ignoreCase); + } + + /** + * Determine if a given string doesn't contain a given substring. + * + * @param iterable|string $needles + */ + public function doesntContain(string|iterable $needles, bool $ignoreCase = false): bool + { + return Str::doesntContain($this->value, $needles, $ignoreCase); + } + + /** + * Convert the case of a string. + */ + public function convertCase(int $mode = MB_CASE_FOLD, ?string $encoding = 'UTF-8'): static + { + return new static(Str::convertCase($this->value, $mode, $encoding)); + } + + /** + * Replace consecutive instances of a given character with a single character. + */ + public function deduplicate(string $character = ' '): static + { + return new static(Str::deduplicate($this->value, $character)); + } + + /** + * Get the parent directory's path. + */ + public function dirname(int $levels = 1): static + { + return new static(dirname($this->value, $levels)); + } + + /** + * Determine if a given string ends with a given substring. + * + * @param iterable|string $needles + */ + public function endsWith(string|iterable $needles): bool + { + return Str::endsWith($this->value, $needles); + } + + /** + * Determine if a given string doesn't end with a given substring. + * + * @param iterable|string $needles + */ + public function doesntEndWith(string|iterable $needles): bool + { + return Str::doesntEndWith($this->value, $needles); + } + + /** + * Determine if the string is an exact match with the given value. + */ + public function exactly(Stringable|string $value): bool + { + if ($value instanceof Stringable) { + $value = $value->toString(); + } + + return $this->value === $value; + } + + /** + * Extracts an excerpt from text that matches the first instance of a phrase. + */ + public function excerpt(string $phrase = '', array $options = []): ?string + { + return Str::excerpt($this->value, $phrase, $options); + } + + /** + * Explode the string into a collection. + * + * @return Collection + */ + public function explode(string $delimiter, int $limit = PHP_INT_MAX): Collection + { + return new Collection(explode($delimiter, $this->value, $limit)); + } + + /** + * Split a string using a regular expression or by length. + * + * @return Collection + */ + 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)); // @phpstan-ignore return.type + } + + $segments = preg_split($pattern, $this->value, $limit, $flags); + + return ! empty($segments) ? new Collection($segments) : new Collection(); // @phpstan-ignore return.type + } + + /** + * Cap a string with a single instance of a given value. + */ + public function finish(string $cap): static + { + return new static(Str::finish($this->value, $cap)); + } + + /** + * Determine if a given string matches a given pattern. + * + * @param iterable|string $pattern + */ + public function is(string|iterable $pattern, bool $ignoreCase = false): bool + { + return Str::is($pattern, $this->value, $ignoreCase); + } + + /** + * Determine if a given string is 7 bit ASCII. + */ + public function isAscii(): bool + { + return Str::isAscii($this->value); + } + + /** + * Determine if a given string is valid JSON. + */ + public function isJson(): bool + { + return Str::isJson($this->value); + } + + /** + * Determine if a given value is a valid URL. + */ + public function isUrl(array $protocols = []): bool + { + return Str::isUrl($this->value, $protocols); + } + + /** + * Determine if a given string is a valid UUID. + * + * @param null|'max'|int<0, 8> $version + */ + public function isUuid(int|string|null $version = null): bool + { + return Str::isUuid($this->value, $version); + } + + /** + * Determine if a given string is a valid ULID. + */ + public function isUlid(): bool + { + return Str::isUlid($this->value); + } + + /** + * Determine if the given string is empty. + */ + public function isEmpty(): bool + { + return $this->value === ''; + } + + /** + * Determine if the given string is not empty. + */ + public function isNotEmpty(): bool + { + return ! $this->isEmpty(); + } + + /** + * Convert a string to kebab case. + */ + public function kebab(): static + { + return new static(Str::kebab($this->value)); + } + + /** + * Return the length of the given string. + */ + public function length(?string $encoding = null): int + { + return Str::length($this->value, $encoding); + } + + /** + * Limit the number of characters in a string. + */ + public function limit(int $limit = 100, string $end = '...', bool $preserveWords = false): static + { + return new static(Str::limit($this->value, $limit, $end, $preserveWords)); + } + + /** + * Convert the given string to lower-case. + */ + public function lower(): static + { + return new static(Str::lower($this->value)); + } + + /** + * Convert GitHub flavored Markdown into HTML. + */ + public function markdown(array $options = [], array $extensions = []): static + { + return new static(Str::markdown($this->value, $options, $extensions)); + } + + /** + * Convert inline Markdown into HTML. + */ + public function inlineMarkdown(array $options = [], array $extensions = []): static + { + return new static(Str::inlineMarkdown($this->value, $options, $extensions)); + } + + /** + * Masks a portion of a string with a repeated character. + */ + public function mask(string $character, int $index, ?int $length = null, string $encoding = 'UTF-8'): static + { + return new static(Str::mask($this->value, $character, $index, $length, $encoding)); + } + + /** + * Get the string matching the given pattern. + */ + public function match(string $pattern): static + { + return new static(Str::match($pattern, $this->value)); + } + + /** + * Determine if a given string matches a given pattern. + * + * @param iterable|string $pattern + */ + public function isMatch(string|iterable $pattern): bool + { + return Str::isMatch($pattern, $this->value); + } + + /** + * Get the string matching the given pattern. + */ + public function matchAll(string $pattern): Collection + { + return Str::matchAll($pattern, $this->value); + } + + /** + * Determine if the string matches the given pattern. + */ + public function test(string $pattern): bool + { + return $this->isMatch($pattern); + } + + /** + * Remove all non-numeric characters from a string. + */ + public function numbers(): static + { + return new static(Str::numbers($this->value)); + } + + /** + * Pad both sides of the string with another. + */ + public function padBoth(int $length, string $pad = ' '): static + { + return new static(Str::padBoth($this->value, $length, $pad)); + } + + /** + * Pad the left side of the string with another. + */ + public function padLeft(int $length, string $pad = ' '): static + { + return new static(Str::padLeft($this->value, $length, $pad)); + } + + /** + * Pad the right side of the string with another. + */ + public function padRight(int $length, string $pad = ' '): static + { + return new static(Str::padRight($this->value, $length, $pad)); + } + + /** + * Parse a Class@method style callback into class and method. + * + * @return array + */ + public function parseCallback(?string $default = null): array + { + return Str::parseCallback($this->value, $default); + } + + /** + * Call the given callback and return a new string. + */ + public function pipe(callable $callback): static + { + return new static($callback($this)); + } + + /** + * Get the plural form of an English word. + */ + public function plural(int|array|Countable $count = 2, bool $prependCount = false): static + { + return new static(Str::plural($this->value, $count, $prependCount)); + } + + /** + * Pluralize the last word of an English, studly caps case string. + */ + public function pluralStudly(int|array|Countable $count = 2): static + { + return new static(Str::pluralStudly($this->value, $count)); + } + + /** + * Pluralize the last word of an English, Pascal caps case string. + */ + public function pluralPascal(int|array|Countable $count = 2): static + { + return new static(Str::pluralStudly($this->value, $count)); + } + + /** + * Find the multi-byte safe position of the first occurrence of the given substring. + */ + public function position(string $needle, int $offset = 0, ?string $encoding = null): int|false + { + return Str::position($this->value, $needle, $offset, $encoding); + } + + /** + * Prepend the given values to the string. + */ + public function prepend(string ...$values): static + { + return new static(implode('', $values) . $this->value); + } + + /** + * Remove any occurrence of the given string in the subject. + * + * @param iterable|string $search + */ + public function remove(string|iterable $search, bool $caseSensitive = true): static + { + return new static(Str::remove($search, $this->value, $caseSensitive)); + } + + /** + * Reverse the string. + */ + public function reverse(): static + { + return new static(Str::reverse($this->value)); + } + + /** + * Repeat the string. + */ + public function repeat(int $times): static + { + return new static(str_repeat($this->value, $times)); + } + + /** + * Replace the given value in the given string. + * + * @param iterable|string $search + * @param iterable|string $replace + */ + public function replace(string|iterable $search, string|iterable $replace, bool $caseSensitive = true): static + { + return new static(Str::replace($search, $replace, $this->value, $caseSensitive)); + } + + /** + * Replace a given value in the string sequentially with an array. + * + * @param iterable $replace + */ + public function replaceArray(string $search, iterable $replace): static + { + return new static(Str::replaceArray($search, $replace, $this->value)); + } + + /** + * Replace the first occurrence of a given value in the string. + */ + public function replaceFirst(string $search, string $replace): static + { + return new static(Str::replaceFirst($search, $replace, $this->value)); + } + + /** + * Replace the first occurrence of the given value if it appears at the start of the string. + */ + public function replaceStart(string $search, string $replace): static + { + return new static(Str::replaceStart($search, $replace, $this->value)); + } + + /** + * Replace the last occurrence of a given value in the string. + */ + public function replaceLast(string $search, string $replace): static + { + return new static(Str::replaceLast($search, $replace, $this->value)); + } + + /** + * Replace the last occurrence of a given value if it appears at the end of the string. + */ + public function replaceEnd(string $search, string $replace): static + { + return new static(Str::replaceEnd($search, $replace, $this->value)); + } + + /** + * Replace the patterns matching the given regular expression. + * + * @param Closure|string|string[] $replace + */ + public function replaceMatches(array|string $pattern, Closure|array|string $replace, int $limit = -1): static + { + if ($replace instanceof Closure) { + return new static(preg_replace_callback($pattern, $replace, $this->value, $limit)); + } + + return new static(preg_replace($pattern, $replace, $this->value, $limit)); + } + + /** + * Parse input from a string to a collection, according to a format. + */ + public function scan(string $format): Collection + { + return new Collection(sscanf($this->value, $format)); + } + + /** + * Remove all "extra" blank space from the given string. + */ + public function squish(): static + { + return new static(Str::squish($this->value)); + } + + /** + * Begin a string with a single instance of a given value. + */ + public function start(string $prefix): static + { + return new static(Str::start($this->value, $prefix)); + } + + /** + * Strip HTML and PHP tags from the given string. + * + * @param null|string|string[] $allowedTags + */ + public function stripTags(array|string|null $allowedTags = null): static + { + return new static(strip_tags($this->value, $allowedTags)); + } + + /** + * Convert the given string to upper-case. + */ + public function upper(): static + { + return new static(Str::upper($this->value)); + } + + /** + * Convert the given string to proper case. + */ + public function title(): static + { + return new static(Str::title($this->value)); + } + + /** + * Convert the given string to proper case for each word. + */ + public function headline(): static + { + return new static(Str::headline($this->value)); + } + + /** + * Convert the given string to APA-style title case. + */ + public function apa(): static + { + return new static(Str::apa($this->value)); + } + + /** + * Transliterate a string to its closest ASCII representation. + */ + public function transliterate(?string $unknown = '?', ?bool $strict = false): static + { + return new static(Str::transliterate($this->value, $unknown, $strict)); + } + + /** + * Get the singular form of an English word. + */ + public function singular(): static + { + return new static(Str::singular($this->value)); + } + + /** + * Generate a URL friendly "slug" from a given string. + * + * @param array $dictionary + */ + public function slug(string $separator = '-', ?string $language = 'en', array $dictionary = ['@' => 'at']): static + { + return new static(Str::slug($this->value, $separator, $language, $dictionary)); + } + + /** + * Convert a string to snake case. + */ + public function snake(string $delimiter = '_'): static + { + return new static(Str::snake($this->value, $delimiter)); + } + + /** + * Determine if a given string starts with a given substring. + * + * @param iterable|string $needles + */ + public function startsWith(string|iterable $needles): bool + { + return Str::startsWith($this->value, $needles); + } + + /** + * Determine if a given string doesn't start with a given substring. + * + * @param iterable|string $needles + */ + public function doesntStartWith(string|iterable $needles): bool + { + return Str::doesntStartWith($this->value, $needles); + } + + /** + * Convert a value to studly caps case. + */ + public function studly(): static + { + return new static(Str::studly($this->value)); + } + + /** + * Convert the string to Pascal case. + */ + public function pascal(): static + { + return new static(Str::pascal($this->value)); + } + + /** + * Returns the portion of the string specified by the start and length parameters. + */ + public function substr(int $start, ?int $length = null, string $encoding = 'UTF-8'): static + { + return new static(Str::substr($this->value, $start, $length, $encoding)); + } + + /** + * Returns the number of substring occurrences. + */ + public function substrCount(string $needle, int $offset = 0, ?int $length = null): int + { + return Str::substrCount($this->value, $needle, $offset, $length); + } + + /** + * Replace text within a portion of a string. + * + * @param string|string[] $replace + * @param int|int[] $offset + * @param null|int|int[] $length + */ + public function substrReplace(string|array $replace, int|array $offset = 0, int|array|null $length = null): static + { + return new static(Str::substrReplace($this->value, $replace, $offset, $length)); + } + + /** + * Swap multiple keywords in a string with other keywords. + */ + public function swap(array $map): static + { + return new static(strtr($this->value, $map)); + } + + /** + * Take the first or last {$limit} characters. + */ + public function take(int $limit): static + { + if ($limit < 0) { + return $this->substr($limit); + } + + return $this->substr(0, $limit); + } + + /** + * Trim the string of the given characters. + */ + public function trim(?string $characters = null): static + { + return new static(Str::trim(...array_merge([$this->value], func_get_args()))); + } + + /** + * Left trim the string of the given characters. + */ + public function ltrim(?string $characters = null): static + { + return new static(Str::ltrim(...array_merge([$this->value], func_get_args()))); + } + + /** + * Right trim the string of the given characters. + */ + public function rtrim(?string $characters = null): static + { + return new static(Str::rtrim(...array_merge([$this->value], func_get_args()))); + } + + /** + * Make a string's first character lowercase. + */ + public function lcfirst(): static + { + return new static(Str::lcfirst($this->value)); + } + + /** + * Make a string's first character uppercase. + */ + public function ucfirst(): static + { + return new static(Str::ucfirst($this->value)); + } + + /** + * Capitalize the first character of each word in a string. + */ + public function ucwords(string $separators = " \t\r\n\f\v"): static + { + return new static(Str::ucwords($this->value, $separators)); + } + + /** + * Split a string by uppercase characters. + * + * @return Collection + */ + public function ucsplit(): Collection + { + return new Collection(Str::ucsplit($this->value)); + } + + /** + * Execute the given callback if the string contains a given substring. + * + * @param iterable|string $needles + */ + public function whenContains(string|iterable $needles, callable $callback, ?callable $default = null): mixed + { + return $this->when($this->contains($needles), $callback, $default); + } + + /** + * Execute the given callback if the string contains all array values. + * + * @param iterable $needles + */ + public function whenContainsAll(iterable $needles, callable $callback, ?callable $default = null): mixed + { + return $this->when($this->containsAll($needles), $callback, $default); + } + + /** + * Execute the given callback if the string is empty. + */ + public function whenEmpty(callable $callback, ?callable $default = null): mixed + { + return $this->when($this->isEmpty(), $callback, $default); + } + + /** + * Execute the given callback if the string is not empty. + */ + public function whenNotEmpty(callable $callback, ?callable $default = null): mixed + { + return $this->when($this->isNotEmpty(), $callback, $default); + } + + /** + * Execute the given callback if the string ends with a given substring. + * + * @param iterable|string $needles + */ + public function whenEndsWith(string|iterable $needles, callable $callback, ?callable $default = null): mixed + { + return $this->when($this->endsWith($needles), $callback, $default); + } + + /** + * Execute the given callback if the string doesn't end with a given substring. + * + * @param iterable|string $needles + */ + public function whenDoesntEndWith(string|iterable $needles, callable $callback, ?callable $default = null): mixed + { + return $this->when($this->doesntEndWith($needles), $callback, $default); + } + + /** + * Execute the given callback if the string is an exact match with the given value. + */ + public function whenExactly(string $value, callable $callback, ?callable $default = null): mixed + { + return $this->when($this->exactly($value), $callback, $default); + } + + /** + * Execute the given callback if the string is not an exact match with the given value. + */ + public function whenNotExactly(string $value, callable $callback, ?callable $default = null): mixed + { + return $this->when(! $this->exactly($value), $callback, $default); + } + + /** + * Execute the given callback if the string matches a given pattern. + * + * @param iterable|string $pattern + */ + public function whenIs(string|iterable $pattern, callable $callback, ?callable $default = null): mixed + { + return $this->when($this->is($pattern), $callback, $default); + } + + /** + * Execute the given callback if the string is 7 bit ASCII. + */ + public function whenIsAscii(callable $callback, ?callable $default = null): mixed + { + return $this->when($this->isAscii(), $callback, $default); + } + + /** + * Execute the given callback if the string is a valid UUID. + */ + public function whenIsUuid(callable $callback, ?callable $default = null): mixed + { + return $this->when($this->isUuid(), $callback, $default); + } + + /** + * Execute the given callback if the string is a valid ULID. + */ + public function whenIsUlid(callable $callback, ?callable $default = null): mixed + { + return $this->when($this->isUlid(), $callback, $default); + } + + /** + * Execute the given callback if the string starts with a given substring. + * + * @param iterable|string $needles + */ + public function whenStartsWith(string|iterable $needles, callable $callback, ?callable $default = null): mixed + { + return $this->when($this->startsWith($needles), $callback, $default); + } + + /** + * Execute the given callback if the string doesn't start with a given substring. + * + * @param iterable|string $needles + */ + public function whenDoesntStartWith(string|iterable $needles, callable $callback, ?callable $default = null): mixed + { + return $this->when($this->doesntStartWith($needles), $callback, $default); + } + + /** + * Execute the given callback if the string matches the given pattern. + */ + public function whenTest(string $pattern, callable $callback, ?callable $default = null): mixed + { + return $this->when($this->test($pattern), $callback, $default); + } + + /** + * Limit the number of words in a string. + */ + public function words(int $words = 100, string $end = '...'): static + { + return new static(Str::words($this->value, $words, $end)); + } + + /** + * Get the number of words a string contains. + */ + public function wordCount(?string $characters = null): int + { + return Str::wordCount($this->value, $characters); + } + + /** + * Wrap a string to a given number of characters. + */ + public function wordWrap(int $characters = 75, string $break = "\n", bool $cutLongWords = false): static + { + return new static(Str::wordWrap($this->value, $characters, $break, $cutLongWords)); + } + + /** + * Wrap the string with the given strings. + */ + public function wrap(string $before, ?string $after = null): static + { + return new static(Str::wrap($this->value, $before, $after)); + } + + /** + * Unwrap the string with the given strings. + */ + public function unwrap(string $before, ?string $after = null): static + { + return new static(Str::unwrap($this->value, $before, $after)); + } + + /** + * Convert the string into a `HtmlString` instance. + */ + public function toHtmlString(): HtmlString + { + return new HtmlString($this->value); + } + + /** + * Convert the string to Base64 encoding. + */ + public function toBase64(): static + { + return new static(base64_encode($this->value)); + } + + /** + * Decode the Base64 encoded string. + */ + public function fromBase64(bool $strict = false): static + { + return new static(base64_decode($this->value, $strict)); + } + + /** + * Convert the string to a vector embedding using AI. + * + * @return array + * + * @throws RuntimeException + */ + public function toEmbeddings(bool $cache = false): array + { + // TODO: Implement AI embedding conversion (requires AI service configuration) + throw new RuntimeException('String to vector embedding conversion is not yet implemented.'); + } + + /** + * Hash the string using the given algorithm. + */ + public function hash(string $algorithm): static + { + return new static(hash($algorithm, $this->value)); + } + + /** + * Encrypt the string. + */ + public function encrypt(bool $serialize = false): static + { + return new static(encrypt($this->value, $serialize)); + } + + /** + * Decrypt the string. + */ + public function decrypt(bool $serialize = false): static + { + return new static(decrypt($this->value, $serialize)); + } + + /** + * Dump the string. + */ + public function dump(mixed ...$args): static + { + dump($this->value, ...$args); + + return $this; + } + + /** + * Get the underlying string value. + */ + public function value(): string + { + return $this->toString(); + } + + /** + * Get the underlying string value. + */ + public function toString(): string + { + return $this->value; + } + + /** + * Get the underlying string value as an integer. + */ + public function toInteger(int $base = 10): int + { + return intval($this->value, $base); + } + + /** + * Get the underlying string value as a float. + */ + public function toFloat(): float + { + return (float) $this->value; + } + + /** + * Get the underlying string value as a boolean. + * + * Returns true when value is "1", "true", "on", and "yes". Otherwise, returns false. + */ + public function toBoolean(): bool + { + return filter_var($this->value, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Get the underlying string value as a Carbon instance. + * + * @throws \Carbon\Exceptions\InvalidFormatException + */ + public function toDate(?string $format = null, ?string $tz = null): mixed + { + if (is_null($format)) { + return Date::parse($this->value, $tz); + } + + return Date::createFromFormat($format, $this->value, $tz); + } + + /** + * Get the underlying string value as a Uri instance. + */ + public function toUri(): Uri + { + return Uri::of($this->value); + } + + /** + * Convert the object to a string when JSON encoded. + */ + public function jsonSerialize(): string + { + return $this->__toString(); + } + + /** + * Determine if the given offset exists. + */ + public function offsetExists(mixed $offset): bool + { + return isset($this->value[$offset]); + } + + /** + * Get the value at the given offset. + */ + public function offsetGet(mixed $offset): string + { + return $this->value[$offset]; + } + + /** + * Set the value at the given offset. + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->value[$offset] = $value; + } + + /** + * Unset the value at the given offset. + */ + public function offsetUnset(mixed $offset): void + { + unset($this->value[$offset]); + } + + /** + * Proxy dynamic properties onto methods. + */ + public function __get(string $key): mixed + { + return $this->{$key}(); + } + + /** + * Get the raw string value. + */ + public function __toString(): string + { + return (string) $this->value; + } } diff --git a/src/support/src/Testing/Fakes/BatchFake.php b/src/support/src/Testing/Fakes/BatchFake.php index d1028b32b..78b343096 100644 --- a/src/support/src/Testing/Fakes/BatchFake.php +++ b/src/support/src/Testing/Fakes/BatchFake.php @@ -5,11 +5,11 @@ namespace Hypervel\Support\Testing\Fakes; use Carbon\CarbonInterface; -use Hyperf\Collection\Collection; -use Hyperf\Collection\Enumerable; use Hypervel\Bus\Batch; use Hypervel\Bus\UpdatedBatchJobCounts; use Hypervel\Support\Carbon; +use Hypervel\Support\Collection; +use Hypervel\Support\Enumerable; use Throwable; class BatchFake extends Batch diff --git a/src/support/src/Testing/Fakes/BatchRepositoryFake.php b/src/support/src/Testing/Fakes/BatchRepositoryFake.php index a8785bcda..20c8a2c54 100644 --- a/src/support/src/Testing/Fakes/BatchRepositoryFake.php +++ b/src/support/src/Testing/Fakes/BatchRepositoryFake.php @@ -6,12 +6,12 @@ use Carbon\CarbonImmutable; use Closure; -use Hyperf\Stringable\Str; use Hypervel\Bus\Batch; -use Hypervel\Bus\Contracts\BatchRepository; use Hypervel\Bus\PendingBatch; use Hypervel\Bus\UpdatedBatchJobCounts; +use Hypervel\Contracts\Bus\BatchRepository; use Hypervel\Support\Carbon; +use Hypervel\Support\Str; class BatchRepositoryFake implements BatchRepository { diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php index 72a33ab5c..edb23cd46 100644 --- a/src/support/src/Testing/Fakes/BusFake.php +++ b/src/support/src/Testing/Fakes/BusFake.php @@ -5,14 +5,14 @@ namespace Hypervel\Support\Testing\Fakes; use Closure; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hypervel\Bus\Batch; use Hypervel\Bus\ChainedBatch; -use Hypervel\Bus\Contracts\BatchRepository; -use Hypervel\Bus\Contracts\QueueingDispatcher; use Hypervel\Bus\PendingBatch; use Hypervel\Bus\PendingChain; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Bus\QueueingDispatcher; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Hypervel\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; use RuntimeException; diff --git a/src/support/src/Testing/Fakes/EventFake.php b/src/support/src/Testing/Fakes/EventFake.php index 0f222134e..021cc3aba 100644 --- a/src/support/src/Testing/Fakes/EventFake.php +++ b/src/support/src/Testing/Fakes/EventFake.php @@ -5,16 +5,18 @@ namespace Hypervel\Support\Testing\Fakes; use Closure; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; -use Hyperf\Stringable\Str; use Hyperf\Support\Traits\ForwardsCalls; +use Hypervel\Contracts\Event\Dispatcher; +use Hypervel\Event\ListenerData; +use Hypervel\Event\QueuedClosure; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Hypervel\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; -use Psr\EventDispatcher\EventDispatcherInterface; use ReflectionFunction; -class EventFake implements Fake, EventDispatcherInterface +class EventFake implements Fake, Dispatcher { use ForwardsCalls; use ReflectsClosures; @@ -22,7 +24,7 @@ class EventFake implements Fake, EventDispatcherInterface /** * The original event dispatcher. */ - protected EventDispatcherInterface $dispatcher; + protected Dispatcher $dispatcher; /** * The event types that should be intercepted instead of dispatched. @@ -42,7 +44,7 @@ class EventFake implements Fake, EventDispatcherInterface /** * Create a new event fake instance. */ - public function __construct(EventDispatcherInterface $dispatcher, array|string $eventsToFake = []) + public function __construct(Dispatcher $dispatcher, array|string $eventsToFake = []) { $this->dispatcher = $dispatcher; $this->eventsToFake = Arr::wrap($eventsToFake); @@ -187,10 +189,12 @@ public function hasDispatched(string $event): bool /** * Register an event listener with the dispatcher. */ - public function listen(array|Closure|string $events, mixed $listener = null): void - { - /* @phpstan-ignore-next-line */ - $this->dispatcher->listen($events, $listener); + public function listen( + array|Closure|QueuedClosure|string $events, + array|Closure|int|QueuedClosure|string|null $listener = null, + int $priority = ListenerData::DEFAULT_PRIORITY + ): void { + $this->dispatcher->listen($events, $listener, $priority); } /** @@ -198,14 +202,37 @@ public function listen(array|Closure|string $events, mixed $listener = null): vo */ public function hasListeners(string $eventName): bool { - /* @phpstan-ignore-next-line */ return $this->dispatcher->hasListeners($eventName); } + /** + * Determine if the given event has any wildcard listeners. + */ + public function hasWildcardListeners(string $eventName): bool + { + return $this->dispatcher->hasWildcardListeners($eventName); + } + + /** + * Get all of the listeners for a given event name. + */ + public function getListeners(object|string $eventName): iterable + { + return $this->dispatcher->getListeners($eventName); + } + + /** + * Gets the raw, unprepared listeners. + */ + public function getRawListeners(): array + { + return $this->dispatcher->getRawListeners(); + } + /** * Register an event and payload to be dispatched later. */ - public function push(string $event, array $payload = []): void + public function push(string $event, mixed $payload = []): void { } @@ -214,7 +241,6 @@ public function push(string $event, array $payload = []): void */ public function subscribe(object|string $subscriber): void { - /* @phpstan-ignore-next-line */ $this->dispatcher->subscribe($subscriber); } @@ -228,18 +254,16 @@ public function flush(string $event): void /** * Fire an event and call the listeners. */ - public function dispatch(object|string $event, mixed $payload = [], bool $halt = false) + public function dispatch(object|string $event, mixed $payload = [], bool $halt = false): mixed { $name = is_object($event) ? get_class($event) : (string) $event; if ($this->shouldFakeEvent($name, $payload)) { $this->events[$name][] = func_get_args(); - /* @phpstan-ignore-next-line */ - return; + return is_object($event) ? $event : null; } - /* @phpstan-ignore-next-line */ return $this->dispatcher->dispatch($event, $payload, $halt); } diff --git a/src/support/src/Testing/Fakes/MailFake.php b/src/support/src/Testing/Fakes/MailFake.php index 19052e1b9..4eff5d91e 100644 --- a/src/support/src/Testing/Fakes/MailFake.php +++ b/src/support/src/Testing/Fakes/MailFake.php @@ -7,17 +7,17 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\Support\Traits\ForwardsCalls; -use Hypervel\Mail\Contracts\Factory; -use Hypervel\Mail\Contracts\Mailable; -use Hypervel\Mail\Contracts\Mailer; -use Hypervel\Mail\Contracts\MailQueue; +use Hypervel\Contracts\Mail\Factory; +use Hypervel\Contracts\Mail\Mailable; +use Hypervel\Contracts\Mail\Mailer; +use Hypervel\Contracts\Mail\MailQueue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Mail\MailManager; use Hypervel\Mail\PendingMail; use Hypervel\Mail\SentMessage; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Hypervel\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; diff --git a/src/support/src/Testing/Fakes/NotificationFake.php b/src/support/src/Testing/Fakes/NotificationFake.php index 5f1169a86..4d8e22ece 100644 --- a/src/support/src/Testing/Fakes/NotificationFake.php +++ b/src/support/src/Testing/Fakes/NotificationFake.php @@ -6,14 +6,14 @@ use Closure; use Exception; -use Hyperf\Collection\Collection; -use Hyperf\Macroable\Macroable; -use Hyperf\Stringable\Str; +use Hypervel\Contracts\Notifications\Dispatcher as NotificationDispatcher; +use Hypervel\Contracts\Notifications\Factory as NotificationFactory; +use Hypervel\Contracts\Translation\HasLocalePreference; use Hypervel\Notifications\AnonymousNotifiable; -use Hypervel\Notifications\Contracts\Dispatcher as NotificationDispatcher; -use Hypervel\Notifications\Contracts\Factory as NotificationFactory; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; +use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Traits\ReflectsClosures; -use Hypervel\Translation\Contracts\HasLocalePreference; use PHPUnit\Framework\Assert as PHPUnit; class NotificationFake implements Fake, NotificationDispatcher, NotificationFactory diff --git a/src/support/src/Testing/Fakes/PendingBatchFake.php b/src/support/src/Testing/Fakes/PendingBatchFake.php index 5fa459a5d..d44057d0a 100644 --- a/src/support/src/Testing/Fakes/PendingBatchFake.php +++ b/src/support/src/Testing/Fakes/PendingBatchFake.php @@ -4,9 +4,9 @@ namespace Hypervel\Support\Testing\Fakes; -use Hyperf\Collection\Collection; use Hypervel\Bus\Batch; use Hypervel\Bus\PendingBatch; +use Hypervel\Support\Collection; class PendingBatchFake extends PendingBatch { diff --git a/src/support/src/Testing/Fakes/PendingMailFake.php b/src/support/src/Testing/Fakes/PendingMailFake.php index 9032edfc4..59b0cc770 100644 --- a/src/support/src/Testing/Fakes/PendingMailFake.php +++ b/src/support/src/Testing/Fakes/PendingMailFake.php @@ -4,7 +4,7 @@ namespace Hypervel\Support\Testing\Fakes; -use Hypervel\Mail\Contracts\Mailable; +use Hypervel\Contracts\Mail\Mailable; use Hypervel\Mail\PendingMail; use Hypervel\Mail\SentMessage; diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php index 12e11c002..1f1e1ce58 100644 --- a/src/support/src/Testing/Fakes/QueueFake.php +++ b/src/support/src/Testing/Fakes/QueueFake.php @@ -8,12 +8,12 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Collection\Collection; +use Hypervel\Contracts\Queue\Factory as FactoryContract; +use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\CallQueuedClosure; -use Hypervel\Queue\Contracts\Factory as FactoryContract; -use Hypervel\Queue\Contracts\Job; -use Hypervel\Queue\Contracts\Queue; use Hypervel\Queue\QueueManager; +use Hypervel\Support\Collection; use Hypervel\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; use Psr\Container\ContainerInterface; diff --git a/src/support/src/Traits/CapsuleManagerTrait.php b/src/support/src/Traits/CapsuleManagerTrait.php new file mode 100644 index 000000000..3623fbea7 --- /dev/null +++ b/src/support/src/Traits/CapsuleManagerTrait.php @@ -0,0 +1,57 @@ +container = $container; + + if (! $this->container->bound('config')) { + $this->container->instance('config', new Fluent()); + } + } + + /** + * Make this capsule instance available globally. + */ + public function setAsGlobal(): void + { + static::$instance = $this; + } + + /** + * Get the IoC container instance. + */ + public function getContainer(): Container + { + return $this->container; + } + + /** + * Set the IoC container instance. + */ + public function setContainer(Container $container): void + { + $this->container = $container; + } +} diff --git a/src/support/src/Traits/Dumpable.php b/src/support/src/Traits/Dumpable.php index cd76acad3..a77ceab17 100644 --- a/src/support/src/Traits/Dumpable.php +++ b/src/support/src/Traits/Dumpable.php @@ -8,22 +8,16 @@ trait Dumpable { /** * Dump the given arguments and terminate execution. - * - * @param mixed ...$args - * @return never */ - public function dd(...$args) + public function dd(mixed ...$args): never { dd($this, ...$args); } /** * Dump the given arguments. - * - * @param mixed ...$args - * @return $this */ - public function dump(...$args) + public function dump(mixed ...$args): static { dump($this, ...$args); diff --git a/src/support/src/Traits/ForwardsCalls.php b/src/support/src/Traits/ForwardsCalls.php new file mode 100644 index 000000000..be16fe781 --- /dev/null +++ b/src/support/src/Traits/ForwardsCalls.php @@ -0,0 +1,74 @@ +{$method}(...$parameters); + } catch (Error|BadMethodCallException $e) { + $pattern = '~^Call to undefined method (?P[^:]+)::(?P[^\(]+)\(\)$~'; + + if (! preg_match($pattern, $e->getMessage(), $matches)) { + throw $e; + } + + if ($matches['class'] !== get_class($object) + || $matches['method'] !== $method) { + throw $e; + } + + static::throwBadMethodCallException($method); + } + } + + /** + * Forward a method call to the given object, returning $this if the forwarded call returned itself. + * + * @param mixed $object + * @param string $method + * @param array $parameters + * @return mixed + * + * @throws BadMethodCallException + */ + protected function forwardDecoratedCallTo($object, $method, $parameters) + { + $result = $this->forwardCallTo($object, $method, $parameters); + + return $result === $object ? $this : $result; + } + + /** + * Throw a bad method call exception for the given method. + * + * @param string $method + * + * @throws BadMethodCallException + */ + protected static function throwBadMethodCallException($method): never + { + throw new BadMethodCallException(sprintf( + 'Call to undefined method %s::%s()', + static::class, + $method + )); + } +} diff --git a/src/support/src/Traits/HasLaravelStyleCommand.php b/src/support/src/Traits/HasLaravelStyleCommand.php index eea46546e..3779f5e00 100644 --- a/src/support/src/Traits/HasLaravelStyleCommand.php +++ b/src/support/src/Traits/HasLaravelStyleCommand.php @@ -4,8 +4,8 @@ namespace Hypervel\Support\Traits; -use Hyperf\Context\ApplicationContext; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Console\Kernel as KernelContract; use Psr\Container\ContainerInterface; trait HasLaravelStyleCommand diff --git a/src/support/src/Traits/Tappable.php b/src/support/src/Traits/Tappable.php index 80a627555..89edf81bf 100644 --- a/src/support/src/Traits/Tappable.php +++ b/src/support/src/Traits/Tappable.php @@ -4,18 +4,15 @@ namespace Hypervel\Support\Traits; -use function Hyperf\Tappable\tap; - trait Tappable { /** * Call the given Closure with this instance then return the instance. * * @param null|(callable($this): mixed) $callback - * @param null|mixed $callback - * @return ($callback is null ? \Hyperf\Tappable\HigherOrderTapProxy : $this) + * @return ($callback is null ? \Hypervel\Support\HigherOrderTapProxy : $this) */ - public function tap($callback = null) + public function tap(?callable $callback = null): mixed { return tap($this, $callback); } diff --git a/src/support/src/Uri.php b/src/support/src/Uri.php index 6858dba4e..0f8925dcf 100644 --- a/src/support/src/Uri.php +++ b/src/support/src/Uri.php @@ -9,9 +9,9 @@ use DateInterval; use DateTimeInterface; use Hyperf\HttpMessage\Server\Response; -use Hypervel\Router\Contracts\UrlRoutable; -use Hypervel\Support\Contracts\Htmlable; -use Hypervel\Support\Contracts\Responsable; +use Hypervel\Contracts\Router\UrlRoutable; +use Hypervel\Contracts\Support\Htmlable; +use Hypervel\Contracts\Support\Responsable; use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Dumpable; use Hypervel\Support\Traits\Macroable; diff --git a/src/support/src/UriQueryString.php b/src/support/src/UriQueryString.php index 8ae347855..29487403e 100644 --- a/src/support/src/UriQueryString.php +++ b/src/support/src/UriQueryString.php @@ -4,7 +4,7 @@ namespace Hypervel\Support; -use Hypervel\Support\Contracts\Arrayable; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Support\Traits\InteractsWithData; use League\Uri\QueryString; use Stringable; diff --git a/src/support/src/ValidatedInput.php b/src/support/src/ValidatedInput.php index b940d283c..8fc6c341c 100644 --- a/src/support/src/ValidatedInput.php +++ b/src/support/src/ValidatedInput.php @@ -5,7 +5,7 @@ namespace Hypervel\Support; use ArrayIterator; -use Hypervel\Support\Contracts\ValidatedData; +use Hypervel\Contracts\Support\ValidatedData; use Hypervel\Support\Traits\InteractsWithData; use Symfony\Component\VarDumper\VarDumper; use Traversable; diff --git a/src/support/src/helpers.php b/src/support/src/helpers.php index 1545b5b3c..6e8aac3af 100644 --- a/src/support/src/helpers.php +++ b/src/support/src/helpers.php @@ -2,80 +2,53 @@ declare(strict_types=1); -use Hyperf\Context\ApplicationContext; -use Hyperf\ViewEngine\Contract\DeferringDisplayableValue; -use Hypervel\Support\Collection; -use Hypervel\Support\Contracts\Htmlable; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Support\DeferringDisplayableValue; +use Hypervel\Contracts\Support\Htmlable; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\Arr; +use Hypervel\Support\Env; use Hypervel\Support\Environment; +use Hypervel\Support\Fluent; use Hypervel\Support\HigherOrderTapProxy; +use Hypervel\Support\Once; +use Hypervel\Support\Onceable; +use Hypervel\Support\Optional; use Hypervel\Support\Sleep; +use Hypervel\Support\Str; +use Hypervel\Support\Stringable as SupportStringable; -if (! function_exists('value')) { +if (! function_exists('append_config')) { /** - * Return the default value of the given value. + * Assign high numeric IDs to a config item to force appending. */ - function value(mixed $value, mixed ...$args) + function append_config(array $array): array { - return \Hypervel\Support\value($value, ...$args); - } -} - -if (! function_exists('env')) { - /** - * Gets the value of an environment variable. - */ - function env(string $key, mixed $default = null): mixed - { - return \Hypervel\Support\env($key, $default); - } -} - -if (! function_exists('environment')) { - /** - * @throws TypeError - */ - function environment(mixed ...$environments): bool|Environment - { - $environment = ApplicationContext::hasContainer() - ? ApplicationContext::getContainer() - ->get(Environment::class) - : new Environment(); - - if (count($environments) > 0) { - return $environment->is(...$environments); - } - - return $environment; - } -} - -if (! function_exists('e')) { - /** - * Encode HTML special characters in a string. - */ - function e(BackedEnum|DeferringDisplayableValue|float|Htmlable|int|string|null $value, bool $doubleEncode = true): string - { - if ($value instanceof DeferringDisplayableValue) { - $value = $value->resolveDisplayableValue(); - } + $start = 9999; - if ($value instanceof Htmlable) { - return $value->toHtml(); - } + foreach ($array as $key => $value) { + if (is_numeric($key)) { + ++$start; - if ($value instanceof BackedEnum) { - $value = $value->value; + $array[$start] = Arr::pull($array, $key); + } } - return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', $doubleEncode); + return $array; } } if (! function_exists('blank')) { /** * Determine if the given value is "blank". + * + * @phpstan-assert-if-false !=null|'' $value + * + * @phpstan-assert-if-true !=numeric|bool $value + * + * @param mixed $value */ - function blank(mixed $value): bool + function blank($value): bool { if (is_null($value)) { return true; @@ -89,119 +62,150 @@ function blank(mixed $value): bool return false; } - if ($value instanceof \Countable) { + if ($value instanceof Model) { + return false; + } + + if ($value instanceof Countable) { return count($value) === 0; } + if ($value instanceof Stringable) { + return trim((string) $value) === ''; + } + return empty($value); } } -if (! function_exists('collect')) { +if (! function_exists('class_basename')) { /** - * Create a collection from the given value. + * Get the class "basename" of the given object / class. + * + * @param object|string $class */ - function collect(mixed $value = null): Collection + function class_basename($class): string { - return new Collection($value); - } -} + $class = is_object($class) ? get_class($class) : $class; -if (! function_exists('data_fill')) { - /** - * Fill in data where it's missing. - */ - function data_fill(mixed &$target, array|string $key, mixed $value): mixed - { - return \Hyperf\Collection\data_set($target, $key, $value, false); + return basename(str_replace('\\', '/', $class)); } } -if (! function_exists('data_get')) { +if (! function_exists('class_uses_recursive')) { /** - * Get an item from an array or object using "dot" notation. + * Returns all traits used by a class, its parent classes and trait of their traits. + * + * @param object|string $class + * @return array */ - function data_get(mixed $target, array|int|string|null $key, mixed $default = null): mixed + function class_uses_recursive($class): array { - return \Hyperf\Collection\data_get($target, $key, $default); - } -} + if (is_object($class)) { + $class = get_class($class); + } -if (! function_exists('data_set')) { - /** - * Set an item on an array or object using dot notation. - */ - function data_set(mixed &$target, array|string $key, mixed $value, bool $overwrite = true): mixed - { - return \Hyperf\Collection\data_set($target, $key, $value, $overwrite); - } -} + $results = []; -if (! function_exists('data_forget')) { - /** - * Remove / unset an item from an array or object using "dot" notation. - */ - function data_forget(mixed &$target, array|int|string|null $key): mixed - { - return \Hyperf\Collection\data_forget($target, $key); + foreach (array_reverse(class_parents($class) ?: []) + [$class => $class] as $class) { + $results += trait_uses_recursive($class); + } + + return array_unique($results); } } -if (! function_exists('head')) { +if (! function_exists('e')) { /** - * Get the first element of an array. Useful for method chaining. + * Encode HTML special characters in a string. + * + * @param null|\BackedEnum|float|\Hypervel\Contracts\Support\DeferringDisplayableValue|\Hypervel\Contracts\Support\Htmlable|int|string $value + * @param bool $doubleEncode */ - function head(array $array): mixed + function e($value, $doubleEncode = true): string { - return reset($array); + if ($value instanceof DeferringDisplayableValue) { + $value = $value->resolveDisplayableValue(); + } + + if ($value instanceof Htmlable) { + return $value->toHtml() ?? ''; + } + + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', $doubleEncode); } } -if (! function_exists('last')) { +if (! function_exists('env')) { /** - * Get the last element from an array. + * Gets the value of an environment variable. */ - function last(array $array): mixed + function env(string $key, mixed $default = null): mixed { - return end($array); + return Env::get($key, $default); } } if (! function_exists('filled')) { /** * Determine if a value is "filled". + * + * @phpstan-assert-if-true !=null|'' $value + * + * @phpstan-assert-if-false !=numeric|bool $value + * + * @param mixed $value */ - function filled(mixed $value): bool + function filled($value): bool { return ! blank($value); } } -if (! function_exists('class_basename')) { +if (! function_exists('fluent')) { /** - * Get the class "basename" of the given object / class. + * Create a Fluent object from the given value. + * + * @param null|iterable|object $value */ - function class_basename(object|string $class): string + function fluent($value = null): Fluent { - return \Hyperf\Support\class_basename($class); + return new Fluent($value ?? []); } } -if (! function_exists('class_uses_recursive')) { +if (! function_exists('literal')) { /** - * Returns all traits used by a class, its parent classes and trait of their traits. + * Return a new literal or anonymous object using named arguments. + * + * @return mixed */ - function class_uses_recursive(object|string $class): array + function literal(...$arguments) { - return \Hyperf\Support\class_uses_recursive($class); + if (count($arguments) === 1 && array_is_list($arguments)) { + return $arguments[0]; + } + + return (object) $arguments; } } if (! function_exists('object_get')) { /** * Get an item from an object using "dot" notation. + * + * @template TValue of object + * + * @param TValue $object + * @param null|string $key + * @param mixed $default + * @return ($key is empty ? TValue : mixed) */ - function object_get(object $object, ?string $key, mixed $default = null): mixed + function object_get($object, $key, $default = null) { if (is_null($key) || trim($key) === '') { return $object; @@ -219,13 +223,86 @@ function object_get(object $object, ?string $key, mixed $default = null): mixed } } +if (! function_exists('environment')) { + /** + * Get the environment instance or check if the environment matches. + * + * @throws TypeError + */ + function environment(mixed ...$environments): bool|Environment + { + $environment = ApplicationContext::hasContainer() + ? ApplicationContext::getContainer() + ->get(Environment::class) + : new Environment(); + + if (count($environments) > 0) { + return $environment->is(...$environments); + } + + return $environment; + } +} + +if (! function_exists('once')) { + /** + * Ensures a callable is only called once, and returns the result on subsequent calls. + * + * @template TReturnType + * + * @param callable(): TReturnType $callback + * @return TReturnType + */ + function once(callable $callback) + { + $onceable = Onceable::tryFromTrace( + debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2), + $callback, + ); + + return $onceable ? Once::instance()->value($onceable) : call_user_func($callback); + } +} + if (! function_exists('optional')) { /** * Provide access to optional objects. + * + * @template TValue + * @template TReturn + * + * @param TValue $value + * @param null|(callable(TValue): TReturn) $callback + * @return ($callback is null ? \Hypervel\Support\Optional : ($value is null ? null : TReturn)) + */ + function optional($value = null, ?callable $callback = null) + { + if (is_null($callback)) { + return new Optional($value); + } + + if (! is_null($value)) { + return $callback($value); + } + + return null; + } +} + +if (! function_exists('preg_replace_array')) { + /** + * Replace a given pattern with each value in the array in sequentially. + * + * @param string $pattern + * @param string $subject */ - function optional(mixed $value = null, ?callable $callback = null): mixed + function preg_replace_array($pattern, array $replacements, $subject): string { - return \Hyperf\Support\optional($value, $callback); + return preg_replace_callback($pattern, function () use (&$replacements) { + foreach ($replacements as $value) { + return array_shift($replacements); + } + }, $subject); } } @@ -233,9 +310,17 @@ function optional(mixed $value = null, ?callable $callback = null): mixed /** * Retry an operation a given number of times. * - * @throws Throwable + * @template TValue + * + * @param array|int $times + * @param callable(int): TValue $callback + * @param \Closure(int, \Throwable): int|int $sleepMilliseconds + * @param null|(callable(\Throwable): bool) $when + * @return TValue + * + * @throws \Throwable */ - function retry(array|int $times, callable $callback, Closure|int $sleepMilliseconds = 0, ?callable $when = null) + function retry($times, callable $callback, $sleepMilliseconds = 0, $when = null) { $attempts = 0; @@ -247,33 +332,65 @@ function retry(array|int $times, callable $callback, Closure|int $sleepMilliseco $times = count($times) + 1; } - beginning: - $attempts++; - --$times; + while (true) { + ++$attempts; + --$times; - try { - return $callback($attempts); - } catch (Throwable $e) { - if ($times < 1 || ($when && ! $when($e))) { - throw $e; - } + try { + return $callback($attempts); + } catch (Throwable $e) { + if ($times < 1 || ($when && ! $when($e))) { + throw $e; + } - $sleepMilliseconds = $backoff[$attempts - 1] ?? $sleepMilliseconds; + $sleepMilliseconds = $backoff[$attempts - 1] ?? $sleepMilliseconds; - if ($sleepMilliseconds) { - Sleep::usleep(value($sleepMilliseconds, $attempts, $e) * 1000); + if ($sleepMilliseconds) { + Sleep::usleep(value($sleepMilliseconds, $attempts, $e) * 1000); + } } + } + } +} - goto beginning; +if (! function_exists('str')) { + /** + * Get a new stringable object from the given string. + * + * @param null|string $string + * @return ($string is null ? object : \Hypervel\Support\Stringable) + */ + function str($string = null) + { + if (func_num_args() === 0) { + return new class { + public function __call($method, $parameters) + { + return Str::$method(...$parameters); + } + + public function __toString() + { + return ''; + } + }; } + + return new SupportStringable($string); } } if (! function_exists('tap')) { /** * Call the given Closure with the given value then return the value. + * + * @template TValue + * + * @param TValue $value + * @param null|(callable(TValue): mixed) $callback + * @return ($callback is null ? \Hypervel\Support\HigherOrderTapProxy : TValue) */ - function tap(mixed $value, ?callable $callback = null): mixed + function tap($value, $callback = null) { if (is_null($callback)) { return new HigherOrderTapProxy($value); @@ -285,11 +402,72 @@ function tap(mixed $value, ?callable $callback = null): mixed } } +if (! function_exists('throw_if')) { + /** + * Throw the given exception if the given condition is true. + * + * @template TValue + * @template TParams of mixed + * @template TException of \Throwable + * @template TExceptionValue of TException|class-string|string + * + * @param TValue $condition + * @param Closure(TParams): TExceptionValue|TExceptionValue $exception + * @param TParams ...$parameters + * @return ($condition is true ? never : ($condition is non-empty-mixed ? never : TValue)) + * + * @throws TException + */ + function throw_if($condition, $exception = 'RuntimeException', ...$parameters) + { + if ($condition) { + if ($exception instanceof Closure) { + $exception = $exception(...$parameters); + } + + if (is_string($exception) && class_exists($exception)) { + $exception = new $exception(...$parameters); + } + + throw is_string($exception) ? new RuntimeException($exception) : $exception; + } + + return $condition; + } +} + +if (! function_exists('throw_unless')) { + /** + * Throw the given exception unless the given condition is true. + * + * @template TValue + * @template TParams of mixed + * @template TException of \Throwable + * @template TExceptionValue of TException|class-string|string + * + * @param TValue $condition + * @param Closure(TParams): TExceptionValue|TExceptionValue $exception + * @param TParams ...$parameters + * @return ($condition is false ? never : ($condition is non-empty-mixed ? TValue : never)) + * + * @throws TException + */ + function throw_unless($condition, $exception = 'RuntimeException', ...$parameters) + { + throw_if(! $condition, $exception, ...$parameters); + + return $condition; + } +} + if (! function_exists('trait_uses_recursive')) { /** * Returns all traits used by a trait and its traits. + * + * @param object|string $trait + * @return array */ - function trait_uses_recursive(object|string $trait): array + function trait_uses_recursive($trait): array { $traits = class_uses($trait) ?: []; @@ -304,8 +482,17 @@ function trait_uses_recursive(object|string $trait): array if (! function_exists('transform')) { /** * Transform the given value if it is present. + * + * @template TValue + * @template TReturn + * @template TDefault + * + * @param TValue $value + * @param callable(TValue): TReturn $callback + * @param callable(TValue): TDefault|TDefault $default + * @return ($value is empty ? TDefault : TReturn) */ - function transform(mixed $value, callable $callback, mixed $default = null): mixed + function transform($value, callable $callback, $default = null) { if (filled($value)) { return $callback($value); @@ -319,79 +506,29 @@ function transform(mixed $value, callable $callback, mixed $default = null): mix } } -if (! function_exists('with')) { +if (! function_exists('windows_os')) { /** - * Return the given value, optionally passed through the given callback. - */ - function with(mixed $value, ?callable $callback = null): mixed - { - return \Hyperf\Support\with($value, $callback); - } -} - -if (! function_exists('throw_if')) { - /** - * Throw the given exception if the given condition is true. - * - * @template T - * - * @param T $condition - * @param string|Throwable $exception - * @param array ...$parameters - * @return T - * @throws Throwable + * Determine whether the current environment is Windows based. */ - function throw_if($condition, $exception, ...$parameters) + function windows_os(): bool { - if ($condition) { - if (is_string($exception) && class_exists($exception)) { - $exception = new $exception(...$parameters); - } - - throw is_string($exception) ? new \RuntimeException($exception) : $exception; - } - - return $condition; + return PHP_OS_FAMILY === 'Windows'; } } -if (! function_exists('throw_unless')) { +if (! function_exists('with')) { /** - * Throw the given exception unless the given condition is true. + * Return the given value, optionally passed through the given callback. * - * @template T + * @template TValue + * @template TReturn * - * @param T $condition - * @param string|Throwable $exception - * @param array ...$parameters - * @return T - * @throws Throwable + * @param TValue $value + * @param null|(callable(TValue): (TReturn)) $callback + * @return ($callback is null ? TValue : TReturn) */ - function throw_unless($condition, $exception, ...$parameters) + function with($value, ?callable $callback = null) { - if (! $condition) { - if (is_string($exception) && class_exists($exception)) { - $exception = new $exception(...$parameters); - } - - throw is_string($exception) ? new \RuntimeException($exception) : $exception; - } - - return $condition; - } -} - -if (! function_exists('when')) { - /** - * @param mixed $expr - * @param mixed $value - * @param mixed $default - * @return mixed - */ - function when($expr, $value = null, $default = null) - { - $result = value($expr) ? $value : $default; - - return $result instanceof \Closure ? $result($expr) : $result; + return is_null($callback) ? $value : $callback($value); } } diff --git a/src/telescope/composer.json b/src/telescope/composer.json index e44b50677..34c58a521 100644 --- a/src/telescope/composer.json +++ b/src/telescope/composer.json @@ -23,9 +23,7 @@ "php": "^8.2", "hyperf/context": "~3.1.0", "hyperf/support": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/tappable": "~3.1.0", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "~0.3.0", "hypervel/core": "^0.3" }, "autoload": { diff --git a/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php b/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php index dd6c78048..846556112 100644 --- a/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php +++ b/src/telescope/database/migrations/2025_02_08_000000_create_telescope_entries_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; use function Hypervel\Config\config; @@ -12,10 +12,10 @@ /** * Get the migration connection name. */ - public function getConnection(): string + public function getConnection(): ?string { return config('telescope.storage.database.connection') - ?: parent::getConnection(); + ?? parent::getConnection(); } /** diff --git a/src/telescope/src/AuthorizesRequests.php b/src/telescope/src/AuthorizesRequests.php index e5cd4a1e3..70280643c 100644 --- a/src/telescope/src/AuthorizesRequests.php +++ b/src/telescope/src/AuthorizesRequests.php @@ -6,7 +6,7 @@ use Closure; use Hyperf\Context\ApplicationContext; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Http\Request as RequestContract; use Hypervel\Support\Environment; trait AuthorizesRequests diff --git a/src/telescope/src/Avatar.php b/src/telescope/src/Avatar.php index 935a01c04..d88f0bbad 100644 --- a/src/telescope/src/Avatar.php +++ b/src/telescope/src/Avatar.php @@ -5,7 +5,7 @@ namespace Hypervel\Telescope; use Closure; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; class Avatar { diff --git a/src/telescope/src/Console/PauseCommand.php b/src/telescope/src/Console/PauseCommand.php index b62fd6478..bbb16630a 100644 --- a/src/telescope/src/Console/PauseCommand.php +++ b/src/telescope/src/Console/PauseCommand.php @@ -4,8 +4,8 @@ namespace Hypervel\Telescope\Console; -use Hypervel\Cache\Contracts\Factory as CacheFactory; use Hypervel\Console\Command; +use Hypervel\Contracts\Cache\Factory as CacheFactory; class PauseCommand extends Command { diff --git a/src/telescope/src/Console/ResumeCommand.php b/src/telescope/src/Console/ResumeCommand.php index b8392391a..cfa6802e6 100644 --- a/src/telescope/src/Console/ResumeCommand.php +++ b/src/telescope/src/Console/ResumeCommand.php @@ -4,8 +4,8 @@ namespace Hypervel\Telescope\Console; -use Hypervel\Cache\Contracts\Factory as CacheFactory; use Hypervel\Console\Command; +use Hypervel\Contracts\Cache\Factory as CacheFactory; class ResumeCommand extends Command { diff --git a/src/telescope/src/Contracts/EntriesRepository.php b/src/telescope/src/Contracts/EntriesRepository.php index 83bcd1fa9..bd0b012d0 100644 --- a/src/telescope/src/Contracts/EntriesRepository.php +++ b/src/telescope/src/Contracts/EntriesRepository.php @@ -4,7 +4,7 @@ namespace Hypervel\Telescope\Contracts; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; use Hypervel\Telescope\EntryResult; use Hypervel\Telescope\Storage\EntryQueryOptions; diff --git a/src/telescope/src/EntryResult.php b/src/telescope/src/EntryResult.php index 0770bdec7..6c1856a8e 100644 --- a/src/telescope/src/EntryResult.php +++ b/src/telescope/src/EntryResult.php @@ -5,7 +5,7 @@ namespace Hypervel\Telescope; use Carbon\CarbonInterface; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; use JsonSerializable; class EntryResult implements JsonSerializable diff --git a/src/telescope/src/ExceptionContext.php b/src/telescope/src/ExceptionContext.php index 7d95b86ec..b919e2ed8 100644 --- a/src/telescope/src/ExceptionContext.php +++ b/src/telescope/src/ExceptionContext.php @@ -4,8 +4,8 @@ namespace Hypervel\Telescope; -use Hyperf\Collection\Collection; -use Hyperf\Stringable\Str; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Throwable; class ExceptionContext diff --git a/src/telescope/src/ExtractProperties.php b/src/telescope/src/ExtractProperties.php index de66d68b1..fa97fb53d 100644 --- a/src/telescope/src/ExtractProperties.php +++ b/src/telescope/src/ExtractProperties.php @@ -4,8 +4,8 @@ namespace Hypervel\Telescope; -use Hyperf\Collection\Collection; -use Hyperf\Database\Model\Model; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\Collection; use ReflectionClass; class ExtractProperties diff --git a/src/telescope/src/ExtractTags.php b/src/telescope/src/ExtractTags.php index 6748074fb..0e7c5b202 100644 --- a/src/telescope/src/ExtractTags.php +++ b/src/telescope/src/ExtractTags.php @@ -4,12 +4,12 @@ namespace Hypervel\Telescope; -use Hyperf\Collection\Collection; -use Hyperf\Database\Model\Model; use Hypervel\Broadcasting\BroadcastEvent; +use Hypervel\Database\Eloquent\Model; use Hypervel\Event\CallQueuedListener; use Hypervel\Mail\SendQueuedMailable; use Hypervel\Notifications\SendQueuedNotifications; +use Hypervel\Support\Collection; use Illuminate\Events\CallQueuedListener as IlluminateCallQueuedListener; use ReflectionClass; use ReflectionException; diff --git a/src/telescope/src/ExtractsMailableTags.php b/src/telescope/src/ExtractsMailableTags.php index 397c0e4e1..3481fc754 100644 --- a/src/telescope/src/ExtractsMailableTags.php +++ b/src/telescope/src/ExtractsMailableTags.php @@ -4,8 +4,8 @@ namespace Hypervel\Telescope; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Mail\Mailable; -use Hypervel\Queue\Contracts\ShouldQueue; trait ExtractsMailableTags { diff --git a/src/telescope/src/FormatModel.php b/src/telescope/src/FormatModel.php index 5a5533094..e1522eb1c 100644 --- a/src/telescope/src/FormatModel.php +++ b/src/telescope/src/FormatModel.php @@ -5,9 +5,9 @@ namespace Hypervel\Telescope; use BackedEnum; -use Hyperf\Collection\Arr; -use Hyperf\Database\Model\Model; -use Hyperf\Database\Model\Relations\Pivot; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Support\Arr; class FormatModel { diff --git a/src/telescope/src/Http/Controllers/DumpController.php b/src/telescope/src/Http/Controllers/DumpController.php index ca24b6d67..d772a20bb 100644 --- a/src/telescope/src/Http/Controllers/DumpController.php +++ b/src/telescope/src/Http/Controllers/DumpController.php @@ -5,7 +5,7 @@ namespace Hypervel\Telescope\Http\Controllers; use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\Contracts\Factory as CacheFactory; +use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Http\Request; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\EntryType; diff --git a/src/telescope/src/Http/Controllers/QueueBatchesController.php b/src/telescope/src/Http/Controllers/QueueBatchesController.php index 7d67f3602..8f761cac3 100644 --- a/src/telescope/src/Http/Controllers/QueueBatchesController.php +++ b/src/telescope/src/Http/Controllers/QueueBatchesController.php @@ -4,8 +4,8 @@ namespace Hypervel\Telescope\Http\Controllers; -use Hyperf\Collection\Collection; -use Hypervel\Bus\Contracts\BatchRepository; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Support\Collection; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\EntryUpdate; diff --git a/src/telescope/src/Http/Controllers/RecordingController.php b/src/telescope/src/Http/Controllers/RecordingController.php index 60cecc934..e682dbf61 100644 --- a/src/telescope/src/Http/Controllers/RecordingController.php +++ b/src/telescope/src/Http/Controllers/RecordingController.php @@ -4,7 +4,7 @@ namespace Hypervel\Telescope\Http\Controllers; -use Hypervel\Cache\Contracts\Factory as CacheFactory; +use Hypervel\Contracts\Cache\Factory as CacheFactory; class RecordingController { diff --git a/src/telescope/src/Http/Middleware/Authorize.php b/src/telescope/src/Http/Middleware/Authorize.php index ee09e0432..df7f2bed2 100644 --- a/src/telescope/src/Http/Middleware/Authorize.php +++ b/src/telescope/src/Http/Middleware/Authorize.php @@ -4,7 +4,7 @@ namespace Hypervel\Telescope\Http\Middleware; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Http\Request as RequestContract; use Hypervel\HttpMessage\Exceptions\HttpException; use Hypervel\Telescope\Telescope; use Psr\Http\Message\ResponseInterface; diff --git a/src/telescope/src/IncomingDumpEntry.php b/src/telescope/src/IncomingDumpEntry.php index 60a6c9923..4d5764911 100644 --- a/src/telescope/src/IncomingDumpEntry.php +++ b/src/telescope/src/IncomingDumpEntry.php @@ -4,7 +4,7 @@ namespace Hypervel\Telescope; -use Hyperf\Collection\Collection; +use Hypervel\Support\Collection; class IncomingDumpEntry extends IncomingEntry { diff --git a/src/telescope/src/IncomingEntry.php b/src/telescope/src/IncomingEntry.php index 8ae4c1fc0..a7d6dad43 100644 --- a/src/telescope/src/IncomingEntry.php +++ b/src/telescope/src/IncomingEntry.php @@ -6,8 +6,8 @@ use DateTimeInterface; use Hyperf\Context\ApplicationContext; -use Hyperf\Stringable\Str; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Support\Str; use Hypervel\Telescope\Contracts\EntriesRepository; class IncomingEntry diff --git a/src/telescope/src/IncomingExceptionEntry.php b/src/telescope/src/IncomingExceptionEntry.php index a2f83d1da..22cd3f106 100644 --- a/src/telescope/src/IncomingExceptionEntry.php +++ b/src/telescope/src/IncomingExceptionEntry.php @@ -4,7 +4,7 @@ namespace Hypervel\Telescope; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Throwable; class IncomingExceptionEntry extends IncomingEntry diff --git a/src/telescope/src/Jobs/ProcessPendingUpdates.php b/src/telescope/src/Jobs/ProcessPendingUpdates.php index 377d06d6c..93e55e389 100644 --- a/src/telescope/src/Jobs/ProcessPendingUpdates.php +++ b/src/telescope/src/Jobs/ProcessPendingUpdates.php @@ -4,12 +4,12 @@ namespace Hypervel\Telescope\Jobs; -use Hyperf\Collection\Collection; use Hypervel\Bus\Dispatchable; use Hypervel\Bus\Queueable; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\SerializesModels; +use Hypervel\Support\Collection; use Hypervel\Telescope\Contracts\EntriesRepository; use function Hypervel\Config\config; diff --git a/src/telescope/src/ListensForStorageOpportunities.php b/src/telescope/src/ListensForStorageOpportunities.php index 1958e6511..65b39e52d 100644 --- a/src/telescope/src/ListensForStorageOpportunities.php +++ b/src/telescope/src/ListensForStorageOpportunities.php @@ -9,7 +9,7 @@ use Hyperf\Command\Event\BeforeHandle as BeforeHandleCommand; use Hyperf\Context\Context; use Hyperf\HttpServer\Event\RequestReceived; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Http\Request as RequestContract; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobProcessed; diff --git a/src/telescope/src/Storage/DatabaseEntriesRepository.php b/src/telescope/src/Storage/DatabaseEntriesRepository.php index f83ac1261..cd7f22fa2 100644 --- a/src/telescope/src/Storage/DatabaseEntriesRepository.php +++ b/src/telescope/src/Storage/DatabaseEntriesRepository.php @@ -5,11 +5,11 @@ namespace Hypervel\Telescope\Storage; use DateTimeInterface; -use Hyperf\Collection\Collection; use Hyperf\Context\Context; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Exception\UniqueConstraintViolationException; -use Hyperf\Database\Query\Builder; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; +use Hypervel\Database\UniqueConstraintViolationException; +use Hypervel\Support\Collection; use Hypervel\Telescope\Contracts\ClearableRepository; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\Contracts\PrunableRepository; diff --git a/src/telescope/src/Storage/EntryModel.php b/src/telescope/src/Storage/EntryModel.php index 364232cea..83a1215b2 100644 --- a/src/telescope/src/Storage/EntryModel.php +++ b/src/telescope/src/Storage/EntryModel.php @@ -4,9 +4,9 @@ namespace Hypervel\Telescope\Storage; -use Hyperf\Collection\Collection; -use Hyperf\Database\Model\Builder; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\Collection; class EntryModel extends Model { diff --git a/src/telescope/src/Telescope.php b/src/telescope/src/Telescope.php index a6fb757b9..4b38586ab 100644 --- a/src/telescope/src/Telescope.php +++ b/src/telescope/src/Telescope.php @@ -6,15 +6,15 @@ use Closure; use Exception; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\Context\ApplicationContext; -use Hyperf\Stringable\Str; use Hypervel\Context\Context; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Contracts\Http\Request as RequestContract; use Hypervel\Log\Events\MessageLogged; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Hypervel\Support\Facades\Auth; +use Hypervel\Support\Str; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\Contracts\TerminableRepository; use Hypervel\Telescope\Jobs\ProcessPendingUpdates; diff --git a/src/telescope/src/TelescopeApplicationServiceProvider.php b/src/telescope/src/TelescopeApplicationServiceProvider.php index 614af2d98..feff4514f 100644 --- a/src/telescope/src/TelescopeApplicationServiceProvider.php +++ b/src/telescope/src/TelescopeApplicationServiceProvider.php @@ -4,7 +4,7 @@ namespace Hypervel\Telescope; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Http\Request as RequestContract; use Hypervel\Support\Facades\Gate; use Hypervel\Support\ServiceProvider; diff --git a/src/telescope/src/Watchers/CacheWatcher.php b/src/telescope/src/Watchers/CacheWatcher.php index e65a6cc10..e85f76398 100644 --- a/src/telescope/src/Watchers/CacheWatcher.php +++ b/src/telescope/src/Watchers/CacheWatcher.php @@ -5,11 +5,11 @@ namespace Hypervel\Telescope\Watchers; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\KeyForgotten; use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Support\Str; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; use Psr\Container\ContainerInterface; diff --git a/src/telescope/src/Watchers/DumpWatcher.php b/src/telescope/src/Watchers/DumpWatcher.php index cbf3ee6b7..29826915c 100644 --- a/src/telescope/src/Watchers/DumpWatcher.php +++ b/src/telescope/src/Watchers/DumpWatcher.php @@ -5,7 +5,7 @@ namespace Hypervel\Telescope\Watchers; use Exception; -use Hypervel\Cache\Contracts\Factory as CacheFactory; +use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Telescope\IncomingDumpEntry; use Hypervel\Telescope\Telescope; use Psr\Container\ContainerInterface; diff --git a/src/telescope/src/Watchers/EventWatcher.php b/src/telescope/src/Watchers/EventWatcher.php index 44010763d..cf579a039 100644 --- a/src/telescope/src/Watchers/EventWatcher.php +++ b/src/telescope/src/Watchers/EventWatcher.php @@ -4,10 +4,10 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Collection; -use Hyperf\Stringable\Str; -use Hypervel\Broadcasting\Contracts\ShouldBroadcast; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Broadcasting\ShouldBroadcast; +use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Hypervel\Telescope\ExtractProperties; use Hypervel\Telescope\ExtractTags; use Hypervel\Telescope\IncomingEntry; @@ -115,15 +115,12 @@ protected function shouldIgnore(string $eventName): bool } /** - * Determine if the event was fired internally by Laravel. + * Determine if the event was fired internally by the framework. */ protected function eventIsFiredByTheFramework(string $eventName): bool { - if (in_array($eventName, ModelWatcher::MODEL_EVENTS)) { - return true; - } - $prefixes = [ + 'eloquent.', // Model events (e.g., "eloquent.created: App\Models\User") 'Hypervel', 'Hyperf', 'FriendsOfHyperf', diff --git a/src/telescope/src/Watchers/ExceptionWatcher.php b/src/telescope/src/Watchers/ExceptionWatcher.php index 9972b69c7..eca9a261c 100644 --- a/src/telescope/src/Watchers/ExceptionWatcher.php +++ b/src/telescope/src/Watchers/ExceptionWatcher.php @@ -4,9 +4,9 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hypervel\Log\Events\MessageLogged; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Hypervel\Telescope\ExceptionContext; use Hypervel\Telescope\ExtractTags; use Hypervel\Telescope\IncomingExceptionEntry; diff --git a/src/telescope/src/Watchers/GateWatcher.php b/src/telescope/src/Watchers/GateWatcher.php index 5287cb9db..d67706188 100644 --- a/src/telescope/src/Watchers/GateWatcher.php +++ b/src/telescope/src/Watchers/GateWatcher.php @@ -4,11 +4,11 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Collection; -use Hyperf\Database\Model\Model; -use Hyperf\Stringable\Str; use Hypervel\Auth\Access\Events\GateEvaluated; use Hypervel\Auth\Access\Response; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Hypervel\Telescope\FormatModel; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; diff --git a/src/telescope/src/Watchers/JobWatcher.php b/src/telescope/src/Watchers/JobWatcher.php index dad6c1547..89059e1ed 100644 --- a/src/telescope/src/Watchers/JobWatcher.php +++ b/src/telescope/src/Watchers/JobWatcher.php @@ -4,14 +4,14 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Arr; -use Hyperf\Database\Model\ModelNotFoundException; -use Hyperf\Stringable\Str; -use Hypervel\Bus\Contracts\BatchRepository; -use Hypervel\Encryption\Contracts\Encrypter; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Encryption\Encrypter; +use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobProcessed; use Hypervel\Queue\Queue; +use Hypervel\Support\Arr; +use Hypervel\Support\Str; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\EntryUpdate; use Hypervel\Telescope\ExceptionContext; diff --git a/src/telescope/src/Watchers/LogWatcher.php b/src/telescope/src/Watchers/LogWatcher.php index 0388e70a8..e6c87048c 100644 --- a/src/telescope/src/Watchers/LogWatcher.php +++ b/src/telescope/src/Watchers/LogWatcher.php @@ -4,8 +4,8 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Arr; use Hypervel\Log\Events\MessageLogged; +use Hypervel\Support\Arr; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; use Psr\Container\ContainerInterface; diff --git a/src/telescope/src/Watchers/MailWatcher.php b/src/telescope/src/Watchers/MailWatcher.php index f21215609..3f9232373 100644 --- a/src/telescope/src/Watchers/MailWatcher.php +++ b/src/telescope/src/Watchers/MailWatcher.php @@ -4,8 +4,8 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Collection; use Hypervel\Mail\Events\MessageSent; +use Hypervel\Support\Collection; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; use Psr\Container\ContainerInterface; diff --git a/src/telescope/src/Watchers/ModelWatcher.php b/src/telescope/src/Watchers/ModelWatcher.php index 36d164d4f..bbdf17605 100644 --- a/src/telescope/src/Watchers/ModelWatcher.php +++ b/src/telescope/src/Watchers/ModelWatcher.php @@ -4,10 +4,9 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Collection; use Hyperf\Context\Context; -use Hyperf\Database\Model\Events\Event; -use Hyperf\Database\Model\Model; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\Collection; use Hypervel\Telescope\FormatModel; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Storage\EntryModel; @@ -19,13 +18,18 @@ class ModelWatcher extends Watcher { public const HYDRATIONS = 'telescope.watcher.model.hydrations'; - public const MODEL_EVENTS = [ - \Hyperf\Database\Model\Events\Created::class, - \Hyperf\Database\Model\Events\Deleted::class, - \Hyperf\Database\Model\Events\ForceDeleted::class, - \Hyperf\Database\Model\Events\Restored::class, - \Hyperf\Database\Model\Events\Retrieved::class, - \Hyperf\Database\Model\Events\Updated::class, + /** + * The model events to watch. + * + * @var list + */ + public const MODEL_ACTIONS = [ + 'created', + 'deleted', + 'forceDeleted', + 'restored', + 'retrieved', + 'updated', ]; /** @@ -39,7 +43,7 @@ class ModelWatcher extends Watcher public function register(ContainerInterface $app): void { $app->get(EventDispatcherInterface::class) - ->listen($this->options['events'] ?? static::MODEL_EVENTS, [$this, 'recordAction']); + ->listen('eloquent.*', [$this, 'recordAction']); Telescope::afterStoring(function () { $this->flushHydrations(); @@ -48,32 +52,50 @@ public function register(ContainerInterface $app): void /** * Record an action. + * + * @param string $eventName The event name (e.g., "eloquent.created: App\Models\User") + * @param Model $model The model instance */ - public function recordAction(Event $event): void + public function recordAction(string $eventName, Model $model): void { - $eventMethod = $event->getMethod(); - if (! Telescope::isRecording() || ! $this->shouldRecord($event)) { + $action = $this->extractAction($eventName); + + if (! Telescope::isRecording() || ! $this->shouldRecord($action, $model)) { return; } - $model = $event->getModel(); - if ($eventMethod === 'retrieved') { + if ($action === 'retrieved') { $this->recordHydrations($model); return; } - $modelClass = FormatModel::given($event->getModel()); + $modelClass = FormatModel::given($model); - $changes = $event->getModel()->getChanges(); + $changes = $model->getChanges(); Telescope::recordModelEvent(IncomingEntry::make(array_filter([ - 'action' => $eventMethod, + 'action' => $action, 'model' => $modelClass, 'changes' => empty($changes) ? null : $changes, ]))->tags([$modelClass])); } + /** + * Extract the action name from the event name. + * + * @param string $eventName Event name like "eloquent.created: App\Models\User" + */ + protected function extractAction(string $eventName): string + { + // Extract "created" from "eloquent.created: App\Models\User" + if (preg_match('/^eloquent\.([a-zA-Z]+):/', $eventName, $matches)) { + return $matches[1]; + } + + return ''; + } + public function getHyDrations(): array { return Context::get(static::HYDRATIONS, []); @@ -137,9 +159,14 @@ public function flushHydrations(): void /** * Determine if the Eloquent event should be recorded. */ - private function shouldRecord(Event $event): bool + private function shouldRecord(string $action, Model $model): bool { - return in_array(get_class($event), static::MODEL_EVENTS); + if (! in_array($action, $this->options['actions'] ?? static::MODEL_ACTIONS)) { + return false; + } + + return Collection::make($this->options['ignore'] ?? [EntryModel::class]) + ->every(fn ($class) => ! $model instanceof $class); } /** diff --git a/src/telescope/src/Watchers/NotificationWatcher.php b/src/telescope/src/Watchers/NotificationWatcher.php index fd5ff4421..3070b3819 100644 --- a/src/telescope/src/Watchers/NotificationWatcher.php +++ b/src/telescope/src/Watchers/NotificationWatcher.php @@ -4,10 +4,10 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Database\Model\Model; +use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Database\Eloquent\Model; use Hypervel\Notifications\AnonymousNotifiable; use Hypervel\Notifications\Events\NotificationSent; -use Hypervel\Queue\Contracts\ShouldQueue; use Hypervel\Telescope\ExtractTags; use Hypervel\Telescope\FormatModel; use Hypervel\Telescope\IncomingEntry; diff --git a/src/telescope/src/Watchers/QueryWatcher.php b/src/telescope/src/Watchers/QueryWatcher.php index 2c2ffe832..4bd845796 100644 --- a/src/telescope/src/Watchers/QueryWatcher.php +++ b/src/telescope/src/Watchers/QueryWatcher.php @@ -4,7 +4,7 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Database\Events\QueryExecuted; +use Hypervel\Database\Events\QueryExecuted; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\Traits\FetchesStackTrace; diff --git a/src/telescope/src/Watchers/RedisWatcher.php b/src/telescope/src/Watchers/RedisWatcher.php index 0fac12c01..47a913ca4 100644 --- a/src/telescope/src/Watchers/RedisWatcher.php +++ b/src/telescope/src/Watchers/RedisWatcher.php @@ -4,10 +4,10 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Collection; use Hyperf\Contract\ConfigInterface; use Hyperf\Redis\Event\CommandExecuted; use Hyperf\Redis\Redis; +use Hypervel\Support\Collection; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; use Psr\Container\ContainerInterface; diff --git a/src/telescope/src/Watchers/RequestWatcher.php b/src/telescope/src/Watchers/RequestWatcher.php index 79bec2c25..ace08ed28 100644 --- a/src/telescope/src/Watchers/RequestWatcher.php +++ b/src/telescope/src/Watchers/RequestWatcher.php @@ -4,16 +4,16 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; use Hyperf\Context\Context; use Hyperf\Contract\ConfigInterface; use Hyperf\HttpServer\Event\RequestHandled; use Hyperf\HttpServer\Router\Dispatched; use Hyperf\HttpServer\Server as HttpServer; use Hyperf\Server\Event; -use Hyperf\Stringable\Str; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; diff --git a/src/telescope/src/Watchers/Traits/FetchesStackTrace.php b/src/telescope/src/Watchers/Traits/FetchesStackTrace.php index cbe100d12..7245dc512 100644 --- a/src/telescope/src/Watchers/Traits/FetchesStackTrace.php +++ b/src/telescope/src/Watchers/Traits/FetchesStackTrace.php @@ -4,8 +4,8 @@ namespace Hypervel\Telescope\Watchers\Traits; -use Hyperf\Collection\Collection; -use Hyperf\Stringable\Str; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; trait FetchesStackTrace { diff --git a/src/telescope/src/Watchers/ViewWatcher.php b/src/telescope/src/Watchers/ViewWatcher.php index c324149a7..2ca92430f 100644 --- a/src/telescope/src/Watchers/ViewWatcher.php +++ b/src/telescope/src/Watchers/ViewWatcher.php @@ -4,10 +4,10 @@ namespace Hypervel\Telescope\Watchers; -use Hyperf\Collection\Collection; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; use Hyperf\ViewEngine\Contract\ViewInterface; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\Traits\FormatsClosure; diff --git a/src/testbench/composer.json b/src/testbench/composer.json index 3bcc442e7..4e4d9756d 100644 --- a/src/testbench/composer.json +++ b/src/testbench/composer.json @@ -21,7 +21,7 @@ ], "require": { "php": "^8.2", - "hypervel/framework": "^0.3", + "hypervel/framework": "*", "mockery/mockery": "^1.6.10", "phpunit/phpunit": "^10.0.7", "symfony/yaml": "^7.3", diff --git a/src/testbench/src/Bootstrapper.php b/src/testbench/src/Bootstrapper.php index 2a0241679..d4c72ae0a 100644 --- a/src/testbench/src/Bootstrapper.php +++ b/src/testbench/src/Bootstrapper.php @@ -4,10 +4,10 @@ namespace Hypervel\Testbench; -use Hyperf\Collection\LazyCollection; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\ClassLoader; use Hypervel\Foundation\Testing\TestScanHandler; +use Hypervel\Support\LazyCollection; use Symfony\Component\Yaml\Yaml; use function Hypervel\Filesystem\join_paths; diff --git a/src/testbench/src/Concerns/CreatesApplication.php b/src/testbench/src/Concerns/CreatesApplication.php new file mode 100644 index 000000000..2522c38d8 --- /dev/null +++ b/src/testbench/src/Concerns/CreatesApplication.php @@ -0,0 +1,59 @@ + + */ + protected function getPackageProviders(ApplicationContract $app): array + { + return []; + } + + /** + * Get package aliases. + * + * @return array + */ + protected function getPackageAliases(ApplicationContract $app): array + { + return []; + } + + /** + * Register package providers. + */ + protected function registerPackageProviders(ApplicationContract $app): void + { + foreach ($this->getPackageProviders($app) as $provider) { + $app->register($provider); + } + } + + /** + * Register package aliases. + */ + protected function registerPackageAliases(ApplicationContract $app): void + { + $aliases = $this->getPackageAliases($app); + + if (empty($aliases)) { + return; + } + + $config = $app->get('config'); + $existing = $config->get('app.aliases', []); + $config->set('app.aliases', array_merge($existing, $aliases)); + } +} diff --git a/src/testbench/src/Concerns/HandlesDatabases.php b/src/testbench/src/Concerns/HandlesDatabases.php new file mode 100644 index 000000000..ed6d918e0 --- /dev/null +++ b/src/testbench/src/Concerns/HandlesDatabases.php @@ -0,0 +1,56 @@ +defineDatabaseMigrations(); + $this->beforeApplicationDestroyed(fn () => $this->destroyDatabaseMigrations()); + + $callback(); + + $this->defineDatabaseSeeders(); + } +} diff --git a/src/testbench/src/Concerns/HandlesRoutes.php b/src/testbench/src/Concerns/HandlesRoutes.php new file mode 100644 index 000000000..79fa34a0d --- /dev/null +++ b/src/testbench/src/Concerns/HandlesRoutes.php @@ -0,0 +1,48 @@ +get(Router::class); + + $this->defineRoutes($router); + + // Only set up web routes group if the method is overridden + // This prevents empty group registration from interfering with other routes + $refMethod = new ReflectionMethod($this, 'defineWebRoutes'); + if ($refMethod->getDeclaringClass()->getName() !== self::class) { + $router->group('/', fn () => $this->defineWebRoutes($router), ['middleware' => ['web']]); + } + } +} diff --git a/src/testbench/src/ConfigProviderRegister.php b/src/testbench/src/ConfigProviderRegister.php index 94cb4f3a3..05f0cc64f 100644 --- a/src/testbench/src/ConfigProviderRegister.php +++ b/src/testbench/src/ConfigProviderRegister.php @@ -4,13 +4,12 @@ namespace Hypervel\Testbench; -use Hyperf\Collection\Arr; +use Hypervel\Support\Arr; class ConfigProviderRegister { protected static $configProviders = [ \Hyperf\Command\ConfigProvider::class, - \Hyperf\Database\SQLite\ConfigProvider::class, \Hyperf\DbConnection\ConfigProvider::class, \Hyperf\Di\ConfigProvider::class, \Hyperf\Dispatcher\ConfigProvider::class, @@ -22,14 +21,13 @@ class ConfigProviderRegister \Hyperf\HttpServer\ConfigProvider::class, \Hyperf\Memory\ConfigProvider::class, \Hyperf\ModelListener\ConfigProvider::class, - \Hyperf\Paginator\ConfigProvider::class, - \Hyperf\Pool\ConfigProvider::class, \Hyperf\Process\ConfigProvider::class, \Hyperf\Redis\ConfigProvider::class, \Hyperf\Serializer\ConfigProvider::class, \Hyperf\Server\ConfigProvider::class, \Hyperf\Signal\ConfigProvider::class, \Hypervel\ConfigProvider::class, + \Hypervel\Database\ConfigProvider::class, \Hypervel\Auth\ConfigProvider::class, \Hypervel\Broadcasting\ConfigProvider::class, \Hypervel\Bus\ConfigProvider::class, diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index 8adc46495..44651c1bd 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -7,23 +7,33 @@ use Hyperf\Context\ApplicationContext; use Hyperf\Coordinator\Constants; use Hyperf\Coordinator\CoordinatorManager; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Application; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; use Hypervel\Foundation\Console\Kernel as ConsoleKernel; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Foundation\Testing\Concerns\HandlesAttributes; +use Hypervel\Foundation\Testing\Concerns\InteractsWithTestCase; use Hypervel\Foundation\Testing\TestCase as BaseTestCase; use Hypervel\Queue\Queue; use Swoole\Timer; use Workbench\App\Exceptions\ExceptionHandler; /** + * Base test case for package testing with testbench features. + * * @internal * @coversNothing */ class TestCase extends BaseTestCase { - protected static $hasBootstrappedTestbench = false; + use Concerns\CreatesApplication; + use Concerns\HandlesDatabases; + use Concerns\HandlesRoutes; + use HandlesAttributes; + use InteractsWithTestCase; + + protected static bool $hasBootstrappedTestbench = false; protected function setUp(): void { @@ -35,9 +45,25 @@ protected function setUp(): void $this->afterApplicationCreated(function () { Timer::clearAll(); CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); + + // Setup routes after application is created (providers are booted) + $this->setUpApplicationRoutes($this->app); }); parent::setUp(); + + // Execute BeforeEach attributes INSIDE coroutine context + // (matches where setUpTraits runs in Foundation TestCase) + $this->runInCoroutine(fn () => $this->setUpTheTestEnvironmentUsingTestCase()); + } + + /** + * Define environment setup. + */ + protected function defineEnvironment(ApplicationContract $app): void + { + $this->registerPackageProviders($app); + $this->registerPackageAliases($app); } protected function createApplication(): ApplicationContract @@ -53,8 +79,32 @@ protected function createApplication(): ApplicationContract protected function tearDown(): void { + // Execute AfterEach attributes INSIDE coroutine context + $this->runInCoroutine(fn () => $this->tearDownTheTestEnvironmentUsingTestCase()); + parent::tearDown(); Queue::createPayloadUsing(null); } + + /** + * Reload the application instance. + */ + protected function reloadApplication(): void + { + $this->tearDown(); + $this->setUp(); + } + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + static::setUpBeforeClassUsingTestCase(); + } + + public static function tearDownAfterClass(): void + { + static::tearDownAfterClassUsingTestCase(); + parent::tearDownAfterClass(); + } } diff --git a/src/testbench/workbench/app/Models/User.php b/src/testbench/workbench/app/Models/User.php index 3c928ac32..24f73bca3 100644 --- a/src/testbench/workbench/app/Models/User.php +++ b/src/testbench/workbench/app/Models/User.php @@ -4,10 +4,16 @@ namespace Workbench\App\Models; +use Hypervel\Database\Eloquent\Attributes\UseFactory; +use Hypervel\Database\Eloquent\Factories\HasFactory; use Hypervel\Foundation\Auth\User as Authenticatable; +use Workbench\Database\Factories\UserFactory; +#[UseFactory(UserFactory::class)] class User extends Authenticatable { + use HasFactory; + /** * The attributes that are mass assignable. */ diff --git a/src/testbench/workbench/config/database.php b/src/testbench/workbench/config/database.php index fe7bf46d2..613f42952 100644 --- a/src/testbench/workbench/config/database.php +++ b/src/testbench/workbench/config/database.php @@ -13,6 +13,46 @@ 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], + 'mysql' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'testing'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => true, + 'engine' => null, + ], + 'mariadb' => [ + 'driver' => 'mariadb', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'testing'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => true, + 'engine' => null, + ], + 'pgsql' => [ + 'driver' => 'pgsql', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'testing'), + 'username' => env('DB_USERNAME', 'postgres'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'schema' => 'public', + 'sslmode' => 'prefer', + ], ], 'redis' => [ 'options' => [ diff --git a/src/testbench/workbench/database/factories/UserFactory.php b/src/testbench/workbench/database/factories/UserFactory.php index f000034ce..0495795e0 100644 --- a/src/testbench/workbench/database/factories/UserFactory.php +++ b/src/testbench/workbench/database/factories/UserFactory.php @@ -2,16 +2,47 @@ declare(strict_types=1); -use Carbon\Carbon; -use Faker\Generator as Faker; +namespace Workbench\Database\Factories; + +use Hypervel\Database\Eloquent\Factories\Factory; +use Hypervel\Support\Str; use Workbench\App\Models\User; -/* @phpstan-ignore-next-line */ -$factory->define(User::class, function (Faker $faker) { - return [ - 'name' => $faker->unique()->name(), - 'email' => $faker->unique()->safeEmail(), - 'email_verified_at' => Carbon::now(), - 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password - ]; -}); +/** + * @extends Factory + */ +class UserFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected ?string $model = User::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + 'email' => $this->faker->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/src/testbench/workbench/database/migrations/2023_08_03_000000_create_users_table.php b/src/testbench/workbench/database/migrations/2023_08_03_000000_create_users_table.php index 358effaf8..b16daff40 100644 --- a/src/testbench/workbench/database/migrations/2023_08_03_000000_create_users_table.php +++ b/src/testbench/workbench/database/migrations/2023_08_03_000000_create_users_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration { diff --git a/src/translation/lang/en/validation.php b/src/translation/lang/en/validation.php index b535c494d..d5ae0ea35 100644 --- a/src/translation/lang/en/validation.php +++ b/src/translation/lang/en/validation.php @@ -33,7 +33,9 @@ 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', 'confirmed' => 'The :attribute confirmation does not match.', + 'contains' => 'The :attribute field is missing a required value.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', @@ -45,6 +47,7 @@ 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.', 'doesnt_end_with' => 'The :attribute must not end with one of the following: :values.', 'doesnt_start_with' => 'The :attribute must not start with one of the following: :values.', 'email' => 'The :attribute must be a valid email address.', diff --git a/src/translation/src/ArrayLoader.php b/src/translation/src/ArrayLoader.php index d67552b11..7612b957f 100644 --- a/src/translation/src/ArrayLoader.php +++ b/src/translation/src/ArrayLoader.php @@ -4,7 +4,7 @@ namespace Hypervel\Translation; -use Hypervel\Translation\Contracts\Loader; +use Hypervel\Contracts\Translation\Loader; class ArrayLoader implements Loader { diff --git a/src/translation/src/ConfigProvider.php b/src/translation/src/ConfigProvider.php index 78a7e5f47..42979995e 100644 --- a/src/translation/src/ConfigProvider.php +++ b/src/translation/src/ConfigProvider.php @@ -4,8 +4,8 @@ namespace Hypervel\Translation; -use Hypervel\Translation\Contracts\Loader as LoaderContract; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; +use Hypervel\Contracts\Translation\Loader as LoaderContract; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; class ConfigProvider { diff --git a/src/translation/src/CreatesPotentiallyTranslatedStrings.php b/src/translation/src/CreatesPotentiallyTranslatedStrings.php index 747d71ca7..03dd149e4 100644 --- a/src/translation/src/CreatesPotentiallyTranslatedStrings.php +++ b/src/translation/src/CreatesPotentiallyTranslatedStrings.php @@ -5,7 +5,7 @@ namespace Hypervel\Translation; use Closure; -use Hypervel\Translation\Contracts\Translator; +use Hypervel\Contracts\Translation\Translator; trait CreatesPotentiallyTranslatedStrings { diff --git a/src/translation/src/FileLoader.php b/src/translation/src/FileLoader.php index fb653d9e7..d642fa780 100644 --- a/src/translation/src/FileLoader.php +++ b/src/translation/src/FileLoader.php @@ -4,9 +4,9 @@ namespace Hypervel\Translation; +use Hypervel\Contracts\Translation\Loader; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; -use Hypervel\Translation\Contracts\Loader; use RuntimeException; class FileLoader implements Loader diff --git a/src/translation/src/Functions.php b/src/translation/src/Functions.php index 18314f895..93a92a943 100644 --- a/src/translation/src/Functions.php +++ b/src/translation/src/Functions.php @@ -6,7 +6,7 @@ use Countable; use Hyperf\Context\ApplicationContext; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; /** * Translate the given message. diff --git a/src/translation/src/LoaderFactory.php b/src/translation/src/LoaderFactory.php index 9844a218d..dc1825884 100644 --- a/src/translation/src/LoaderFactory.php +++ b/src/translation/src/LoaderFactory.php @@ -4,9 +4,9 @@ namespace Hypervel\Translation; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Contracts\Translation\Loader as LoaderContract; use Hypervel\Filesystem\Filesystem; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Translation\Contracts\Loader as LoaderContract; use Psr\Container\ContainerInterface; class LoaderFactory diff --git a/src/translation/src/PotentiallyTranslatedString.php b/src/translation/src/PotentiallyTranslatedString.php index b0e9db0a8..aedffa36b 100644 --- a/src/translation/src/PotentiallyTranslatedString.php +++ b/src/translation/src/PotentiallyTranslatedString.php @@ -5,7 +5,7 @@ namespace Hypervel\Translation; use Countable; -use Hypervel\Translation\Contracts\Translator; +use Hypervel\Contracts\Translation\Translator; use Stringable; class PotentiallyTranslatedString implements Stringable diff --git a/src/translation/src/Translator.php b/src/translation/src/Translator.php index fefd83e88..471153e10 100644 --- a/src/translation/src/Translator.php +++ b/src/translation/src/Translator.php @@ -7,13 +7,13 @@ use Closure; use Countable; use Hyperf\Context\Context; +use Hypervel\Contracts\Translation\Loader; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Support\Arr; use Hypervel\Support\NamespacedItemResolver; use Hypervel\Support\Str; use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Traits\ReflectsClosures; -use Hypervel\Translation\Contracts\Loader; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use InvalidArgumentException; use function Hypervel\Support\enum_value; diff --git a/src/translation/src/TranslatorFactory.php b/src/translation/src/TranslatorFactory.php index 42563d54f..49e851aa9 100644 --- a/src/translation/src/TranslatorFactory.php +++ b/src/translation/src/TranslatorFactory.php @@ -5,8 +5,8 @@ namespace Hypervel\Translation; use Hyperf\Contract\ConfigInterface; -use Hypervel\Translation\Contracts\Loader as LoaderContract; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; +use Hypervel\Contracts\Translation\Loader as LoaderContract; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Psr\Container\ContainerInterface; class TranslatorFactory diff --git a/src/validation/src/ClosureValidationRule.php b/src/validation/src/ClosureValidationRule.php index 1c3268413..bed614b8e 100644 --- a/src/validation/src/ClosureValidationRule.php +++ b/src/validation/src/ClosureValidationRule.php @@ -5,10 +5,10 @@ namespace Hypervel\Validation; use Closure; +use Hypervel\Contracts\Validation\Rule as RuleContract; +use Hypervel\Contracts\Validation\Validator; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Translation\CreatesPotentiallyTranslatedStrings; -use Hypervel\Validation\Contracts\Rule as RuleContract; -use Hypervel\Validation\Contracts\Validator; -use Hypervel\Validation\Contracts\ValidatorAwareRule; class ClosureValidationRule implements RuleContract, ValidatorAwareRule { diff --git a/src/validation/src/Concerns/FormatsMessages.php b/src/validation/src/Concerns/FormatsMessages.php index 7f2365c08..00cec8cb5 100644 --- a/src/validation/src/Concerns/FormatsMessages.php +++ b/src/validation/src/Concerns/FormatsMessages.php @@ -6,9 +6,9 @@ use Closure; use Hyperf\HttpMessage\Upload\UploadedFile; +use Hypervel\Contracts\Validation\Validator; use Hypervel\Support\Arr; use Hypervel\Support\Str; -use Hypervel\Validation\Contracts\Validator; trait FormatsMessages { diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 10ba647d3..7e60e36a9 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -17,9 +17,9 @@ use Egulias\EmailValidator\Validation\NoRFCWarningsValidation; use Egulias\EmailValidator\Validation\RFCValidation; use Exception; -use Hyperf\Database\Model\Model; use Hyperf\HttpMessage\Upload\UploadedFile; use Hypervel\Context\ApplicationContext; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Arr; use Hypervel\Support\Carbon; use Hypervel\Support\Collection; @@ -432,6 +432,26 @@ public function validateContains(string $attribute, mixed $value, mixed $paramet return true; } + /** + * Validate an attribute does not contain a list of values. + * + * @param array $parameters + */ + public function validateDoesntContain(string $attribute, mixed $value, mixed $parameters): bool + { + if (! is_array($value)) { + return false; + } + + foreach ($parameters as $parameter) { + if (in_array($parameter, $value)) { + return false; + } + } + + return true; + } + /** * Validate that the password of the currently authenticated user matches the given value. * @@ -439,8 +459,8 @@ public function validateContains(string $attribute, mixed $value, mixed $paramet */ protected function validateCurrentPassword(string $attribute, mixed $value, mixed $parameters): bool { - $auth = $this->container->get(\Hypervel\Auth\Contracts\Factory::class); - $hasher = $this->container->get(\Hypervel\Hashing\Contracts\Hasher::class); + $auth = $this->container->get(\Hypervel\Contracts\Auth\Factory::class); + $hasher = $this->container->get(\Hypervel\Contracts\Hashing\Hasher::class); $guard = $auth->guard(Arr::first($parameters)); @@ -942,7 +962,7 @@ public function parseTable(string $table): array $table = $model->getTable(); $connection ??= $model->getConnectionName(); - if (str_contains($table, '.') && Str::startsWith($table, $connection)) { + if ($connection !== null && str_contains($table, '.') && Str::startsWith($table, $connection)) { $connection = null; } diff --git a/src/validation/src/ConditionalRules.php b/src/validation/src/ConditionalRules.php index 48003adf7..a028e81f3 100644 --- a/src/validation/src/ConditionalRules.php +++ b/src/validation/src/ConditionalRules.php @@ -5,10 +5,10 @@ namespace Hypervel\Validation; use Closure; +use Hypervel\Contracts\Validation\InvokableRule; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\ValidationRule; use Hypervel\Support\Fluent; -use Hypervel\Validation\Contracts\InvokableRule; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\ValidationRule; class ConditionalRules { diff --git a/src/validation/src/ConfigProvider.php b/src/validation/src/ConfigProvider.php index a7665e250..e21501f1f 100644 --- a/src/validation/src/ConfigProvider.php +++ b/src/validation/src/ConfigProvider.php @@ -4,8 +4,8 @@ namespace Hypervel\Validation; -use Hypervel\Validation\Contracts\Factory as FactoryContract; -use Hypervel\Validation\Contracts\UncompromisedVerifier; +use Hypervel\Contracts\Validation\Factory as FactoryContract; +use Hypervel\Contracts\Validation\UncompromisedVerifier; class ConfigProvider { diff --git a/src/validation/src/DatabasePresenceVerifier.php b/src/validation/src/DatabasePresenceVerifier.php index e61f7a845..b3d28fce3 100644 --- a/src/validation/src/DatabasePresenceVerifier.php +++ b/src/validation/src/DatabasePresenceVerifier.php @@ -5,8 +5,8 @@ namespace Hypervel\Validation; use Closure; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Builder; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; class DatabasePresenceVerifier implements DatabasePresenceVerifierInterface { diff --git a/src/validation/src/Factory.php b/src/validation/src/Factory.php index 0ad8cc96c..7ede4d357 100644 --- a/src/validation/src/Factory.php +++ b/src/validation/src/Factory.php @@ -5,9 +5,9 @@ namespace Hypervel\Validation; use Closure; +use Hypervel\Contracts\Translation\Translator; +use Hypervel\Contracts\Validation\Factory as FactoryContract; use Hypervel\Support\Str; -use Hypervel\Translation\Contracts\Translator; -use Hypervel\Validation\Contracts\Factory as FactoryContract; use Psr\Container\ContainerInterface; class Factory implements FactoryContract diff --git a/src/validation/src/InvokableValidationRule.php b/src/validation/src/InvokableValidationRule.php index 08b3e5b76..36f7b6e53 100644 --- a/src/validation/src/InvokableValidationRule.php +++ b/src/validation/src/InvokableValidationRule.php @@ -4,14 +4,14 @@ namespace Hypervel\Validation; +use Hypervel\Contracts\Validation\DataAwareRule; +use Hypervel\Contracts\Validation\ImplicitRule; +use Hypervel\Contracts\Validation\InvokableRule; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\ValidationRule; +use Hypervel\Contracts\Validation\Validator; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Translation\CreatesPotentiallyTranslatedStrings; -use Hypervel\Validation\Contracts\DataAwareRule; -use Hypervel\Validation\Contracts\ImplicitRule; -use Hypervel\Validation\Contracts\InvokableRule; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\ValidationRule; -use Hypervel\Validation\Contracts\Validator; -use Hypervel\Validation\Contracts\ValidatorAwareRule; class InvokableValidationRule implements Rule, ValidatorAwareRule { diff --git a/src/validation/src/NestedRules.php b/src/validation/src/NestedRules.php index 896e7d1f8..12cdb153c 100644 --- a/src/validation/src/NestedRules.php +++ b/src/validation/src/NestedRules.php @@ -5,7 +5,7 @@ namespace Hypervel\Validation; use Closure; -use Hypervel\Validation\Contracts\CompilableRules; +use Hypervel\Contracts\Validation\CompilableRules; use stdClass; class NestedRules implements CompilableRules diff --git a/src/validation/src/NotPwnedVerifier.php b/src/validation/src/NotPwnedVerifier.php index 7d6117731..b30637192 100644 --- a/src/validation/src/NotPwnedVerifier.php +++ b/src/validation/src/NotPwnedVerifier.php @@ -5,10 +5,10 @@ namespace Hypervel\Validation; use Exception; -use Hyperf\Collection\Collection; +use Hypervel\Contracts\Validation\UncompromisedVerifier; use Hypervel\HttpClient\Factory as HttpClientFactory; +use Hypervel\Support\Collection; use Hypervel\Support\Stringable; -use Hypervel\Validation\Contracts\UncompromisedVerifier; class NotPwnedVerifier implements UncompromisedVerifier { diff --git a/src/validation/src/PresenceVerifierFactory.php b/src/validation/src/PresenceVerifierFactory.php index 39ef0e2df..a56e8ee52 100644 --- a/src/validation/src/PresenceVerifierFactory.php +++ b/src/validation/src/PresenceVerifierFactory.php @@ -4,7 +4,7 @@ namespace Hypervel\Validation; -use Hyperf\Database\ConnectionResolverInterface; +use Hypervel\Database\ConnectionResolverInterface; use Psr\Container\ContainerInterface; class PresenceVerifierFactory diff --git a/src/validation/src/Rule.php b/src/validation/src/Rule.php index fc2652743..07d1b1350 100644 --- a/src/validation/src/Rule.php +++ b/src/validation/src/Rule.php @@ -5,16 +5,18 @@ namespace Hypervel\Validation; use Closure; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Validation\InvokableRule; +use Hypervel\Contracts\Validation\ValidationRule; use Hypervel\Support\Arr; use Hypervel\Support\Traits\Macroable; -use Hypervel\Validation\Contracts\InvokableRule; -use Hypervel\Validation\Contracts\ValidationRule; use Hypervel\Validation\Rules\AnyOf; use Hypervel\Validation\Rules\ArrayRule; use Hypervel\Validation\Rules\Can; +use Hypervel\Validation\Rules\Contains; use Hypervel\Validation\Rules\Date; use Hypervel\Validation\Rules\Dimensions; +use Hypervel\Validation\Rules\DoesntContain; use Hypervel\Validation\Rules\Email; use Hypervel\Validation\Rules\Enum; use Hypervel\Validation\Rules\ExcludeIf; @@ -120,6 +122,30 @@ public static function notIn(array|Arrayable|UnitEnum|string $values): NotIn return new NotIn(is_array($values) ? $values : func_get_args()); } + /** + * Get a contains rule builder instance. + */ + public static function contains(array|Arrayable|UnitEnum|string $values): Contains + { + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + return new Contains(is_array($values) ? $values : func_get_args()); + } + + /** + * Get a doesnt_contain rule builder instance. + */ + public static function doesntContain(array|Arrayable|UnitEnum|string $values): DoesntContain + { + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + return new DoesntContain(is_array($values) ? $values : func_get_args()); + } + /** * Get a required_if rule builder instance. */ @@ -152,6 +178,14 @@ public static function date(): Date return new Date(); } + /** + * Get a datetime rule builder instance. + */ + public static function dateTime(): Date + { + return (new Date())->format('Y-m-d H:i:s'); + } + /** * Get an email rule builder instance. */ diff --git a/src/validation/src/Rules/AnyOf.php b/src/validation/src/Rules/AnyOf.php index 2e14c4c8b..2e0ac0b83 100644 --- a/src/validation/src/Rules/AnyOf.php +++ b/src/validation/src/Rules/AnyOf.php @@ -4,11 +4,11 @@ namespace Hypervel\Validation\Rules; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\Validator as ValidatorContract; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Support\Arr; use Hypervel\Support\Facades\Validator; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\Validator as ValidatorContract; -use Hypervel\Validation\Contracts\ValidatorAwareRule; class AnyOf implements Rule, ValidatorAwareRule { diff --git a/src/validation/src/Rules/ArrayRule.php b/src/validation/src/Rules/ArrayRule.php index 72aaaba0d..10f4dd655 100644 --- a/src/validation/src/Rules/ArrayRule.php +++ b/src/validation/src/Rules/ArrayRule.php @@ -4,7 +4,7 @@ namespace Hypervel\Validation\Rules; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Support\Arrayable; use Stringable; use function Hypervel\Support\enum_value; diff --git a/src/validation/src/Rules/Can.php b/src/validation/src/Rules/Can.php index 74dce6784..7d6c86241 100644 --- a/src/validation/src/Rules/Can.php +++ b/src/validation/src/Rules/Can.php @@ -4,10 +4,10 @@ namespace Hypervel\Validation\Rules; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\Validator; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Support\Facades\Gate; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\Validator; -use Hypervel\Validation\Contracts\ValidatorAwareRule; class Can implements Rule, ValidatorAwareRule { diff --git a/src/validation/src/Rules/Contains.php b/src/validation/src/Rules/Contains.php new file mode 100644 index 000000000..e50362e68 --- /dev/null +++ b/src/validation/src/Rules/Contains.php @@ -0,0 +1,46 @@ +toArray(); + } + + $this->values = is_array($values) ? $values : func_get_args(); + } + + /** + * Convert the rule to a validation string. + */ + public function __toString(): string + { + $values = array_map(function ($value) { + $value = enum_value($value); + + return '"' . str_replace('"', '""', (string) $value) . '"'; + }, $this->values); + + return 'contains:' . implode(',', $values); + } +} diff --git a/src/validation/src/Rules/DatabaseRule.php b/src/validation/src/Rules/DatabaseRule.php index 2fd614545..52d233e79 100644 --- a/src/validation/src/Rules/DatabaseRule.php +++ b/src/validation/src/Rules/DatabaseRule.php @@ -6,8 +6,8 @@ use BackedEnum; use Closure; -use Hyperf\Contract\Arrayable; -use Hyperf\Database\Model\Model; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; use function Hypervel\Support\enum_value; diff --git a/src/validation/src/Rules/DoesntContain.php b/src/validation/src/Rules/DoesntContain.php new file mode 100644 index 000000000..74ed48fa3 --- /dev/null +++ b/src/validation/src/Rules/DoesntContain.php @@ -0,0 +1,46 @@ +toArray(); + } + + $this->values = is_array($values) ? $values : func_get_args(); + } + + /** + * Convert the rule to a validation string. + */ + public function __toString(): string + { + $values = array_map(function ($value) { + $value = enum_value($value); + + return '"' . str_replace('"', '""', (string) $value) . '"'; + }, $this->values); + + return 'doesnt_contain:' . implode(',', $values); + } +} diff --git a/src/validation/src/Rules/Email.php b/src/validation/src/Rules/Email.php index 7f89ff2a5..2d14bf429 100644 --- a/src/validation/src/Rules/Email.php +++ b/src/validation/src/Rules/Email.php @@ -4,14 +4,14 @@ namespace Hypervel\Validation\Rules; +use Hypervel\Contracts\Validation\DataAwareRule; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\Validator as ValidatorContract; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Support\Arr; use Hypervel\Support\Facades\Validator; use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Macroable; -use Hypervel\Validation\Contracts\DataAwareRule; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\Validator as ValidatorContract; -use Hypervel\Validation\Contracts\ValidatorAwareRule; use InvalidArgumentException; class Email implements Rule, DataAwareRule, ValidatorAwareRule diff --git a/src/validation/src/Rules/Enum.php b/src/validation/src/Rules/Enum.php index 503346943..e91d4b368 100644 --- a/src/validation/src/Rules/Enum.php +++ b/src/validation/src/Rules/Enum.php @@ -4,12 +4,12 @@ namespace Hypervel\Validation\Rules; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\Validator; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Support\Arr; use Hypervel\Support\Traits\Conditionable; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\Validator; -use Hypervel\Validation\Contracts\ValidatorAwareRule; use TypeError; use UnitEnum; diff --git a/src/validation/src/Rules/File.php b/src/validation/src/Rules/File.php index fe824979a..f0c290862 100644 --- a/src/validation/src/Rules/File.php +++ b/src/validation/src/Rules/File.php @@ -4,16 +4,16 @@ namespace Hypervel\Validation\Rules; +use Hypervel\Contracts\Validation\DataAwareRule; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\Validator as ValidatorContract; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Validator; use Hypervel\Support\Str; use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Macroable; -use Hypervel\Validation\Contracts\DataAwareRule; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\Validator as ValidatorContract; -use Hypervel\Validation\Contracts\ValidatorAwareRule; use InvalidArgumentException; use Stringable; diff --git a/src/validation/src/Rules/In.php b/src/validation/src/Rules/In.php index 72eff8aa9..755c5737e 100644 --- a/src/validation/src/Rules/In.php +++ b/src/validation/src/Rules/In.php @@ -4,7 +4,7 @@ namespace Hypervel\Validation\Rules; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Support\Arrayable; use Stringable; use UnitEnum; diff --git a/src/validation/src/Rules/NotIn.php b/src/validation/src/Rules/NotIn.php index f41f39c63..4f5f312b6 100644 --- a/src/validation/src/Rules/NotIn.php +++ b/src/validation/src/Rules/NotIn.php @@ -4,7 +4,7 @@ namespace Hypervel\Validation\Rules; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Support\Arrayable; use Stringable; use UnitEnum; diff --git a/src/validation/src/Rules/Password.php b/src/validation/src/Rules/Password.php index fddb32a17..50af74131 100644 --- a/src/validation/src/Rules/Password.php +++ b/src/validation/src/Rules/Password.php @@ -6,14 +6,14 @@ use Closure; use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Validation\DataAwareRule; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\UncompromisedVerifier; +use Hypervel\Contracts\Validation\Validator as ValidatorContract; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Support\Arr; use Hypervel\Support\Facades\Validator; use Hypervel\Support\Traits\Conditionable; -use Hypervel\Validation\Contracts\DataAwareRule; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\UncompromisedVerifier; -use Hypervel\Validation\Contracts\Validator as ValidatorContract; -use Hypervel\Validation\Contracts\ValidatorAwareRule; use InvalidArgumentException; class Password implements Rule, DataAwareRule, ValidatorAwareRule diff --git a/src/validation/src/Rules/Unique.php b/src/validation/src/Rules/Unique.php index 44807a705..8975e8ca4 100644 --- a/src/validation/src/Rules/Unique.php +++ b/src/validation/src/Rules/Unique.php @@ -4,7 +4,7 @@ namespace Hypervel\Validation\Rules; -use Hyperf\Database\Model\Model; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Traits\Conditionable; use Stringable; diff --git a/src/validation/src/ValidatesWhenResolvedTrait.php b/src/validation/src/ValidatesWhenResolvedTrait.php index fcb2ce08c..3ba150cc7 100644 --- a/src/validation/src/ValidatesWhenResolvedTrait.php +++ b/src/validation/src/ValidatesWhenResolvedTrait.php @@ -4,7 +4,7 @@ namespace Hypervel\Validation; -use Hypervel\Validation\Contracts\Validator; +use Hypervel\Contracts\Validation\Validator; /** * Provides default implementation of ValidatesWhenResolved contract. diff --git a/src/validation/src/ValidationException.php b/src/validation/src/ValidationException.php index 4c397a766..6e71a659a 100644 --- a/src/validation/src/ValidationException.php +++ b/src/validation/src/ValidationException.php @@ -5,9 +5,9 @@ namespace Hypervel\Validation; use Exception; +use Hypervel\Contracts\Validation\Validator as ValidatorContract; use Hypervel\Support\Arr; use Hypervel\Support\Facades\Validator; -use Hypervel\Validation\Contracts\Validator as ValidatorContract; use Psr\Http\Message\ResponseInterface; class ValidationException extends Exception diff --git a/src/validation/src/ValidationRuleParser.php b/src/validation/src/ValidationRuleParser.php index 7e0e8d5c9..faa421e9f 100644 --- a/src/validation/src/ValidationRuleParser.php +++ b/src/validation/src/ValidationRuleParser.php @@ -5,14 +5,14 @@ namespace Hypervel\Validation; use Closure; +use Hypervel\Contracts\Validation\CompilableRules; +use Hypervel\Contracts\Validation\InvokableRule; +use Hypervel\Contracts\Validation\Rule as RuleContract; +use Hypervel\Contracts\Validation\ValidationRule; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Str; use Hypervel\Support\StrCache; -use Hypervel\Validation\Contracts\CompilableRules; -use Hypervel\Validation\Contracts\InvokableRule; -use Hypervel\Validation\Contracts\Rule as RuleContract; -use Hypervel\Validation\Contracts\ValidationRule; use Hypervel\Validation\Rules\Date; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\Numeric; diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index 0473fbfdf..3536add67 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -7,6 +7,13 @@ use BadMethodCallException; use Closure; use Hyperf\HttpMessage\Upload\UploadedFile; +use Hypervel\Contracts\Translation\Translator; +use Hypervel\Contracts\Validation\DataAwareRule; +use Hypervel\Contracts\Validation\ImplicitRule; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\Rule as RuleContract; +use Hypervel\Contracts\Validation\Validator as ValidatorContract; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Fluent; @@ -14,13 +21,6 @@ use Hypervel\Support\Str; use Hypervel\Support\StrCache; use Hypervel\Support\ValidatedInput; -use Hypervel\Translation\Contracts\Translator; -use Hypervel\Validation\Contracts\DataAwareRule; -use Hypervel\Validation\Contracts\ImplicitRule; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\Rule as RuleContract; -use Hypervel\Validation\Contracts\Validator as ValidatorContract; -use Hypervel\Validation\Contracts\ValidatorAwareRule; use InvalidArgumentException; use Psr\Container\ContainerInterface; use RuntimeException; diff --git a/src/validation/src/ValidatorFactory.php b/src/validation/src/ValidatorFactory.php index 29867faad..a1e5655f9 100644 --- a/src/validation/src/ValidatorFactory.php +++ b/src/validation/src/ValidatorFactory.php @@ -4,8 +4,8 @@ namespace Hypervel\Validation; -use Hyperf\Database\ConnectionResolverInterface; -use Hypervel\Translation\Contracts\Translator; +use Hypervel\Contracts\Translation\Translator; +use Hypervel\Database\ConnectionResolverInterface; use Psr\Container\ContainerInterface; class ValidatorFactory diff --git a/tests/AfterEachTestExtension.php b/tests/AfterEachTestExtension.php new file mode 100644 index 000000000..e73199400 --- /dev/null +++ b/tests/AfterEachTestExtension.php @@ -0,0 +1,24 @@ +registerSubscriber(new AfterEachTestSubscriber()); + } +} diff --git a/tests/AfterEachTestSubscriber.php b/tests/AfterEachTestSubscriber.php new file mode 100644 index 000000000..3a09924be --- /dev/null +++ b/tests/AfterEachTestSubscriber.php @@ -0,0 +1,26 @@ +shouldReceive('find')->once()->with(1)->andReturn(['id' => 1, 'name' => 'Dayle']); + $builder->shouldReceive('find')->once()->with(1)->andReturn((object) ['id' => 1, 'name' => 'Dayle']); $conn = m::mock(ConnectionInterface::class); $conn->shouldReceive('table')->once()->with('foo')->andReturn($builder); $hasher = m::mock(Hasher::class); @@ -52,7 +52,7 @@ public function testRetrieveByCredentialsReturnsUserWhenUserIsFound() $builder = m::mock(Builder::class); $builder->shouldReceive('where')->once()->with('username', 'dayle'); $builder->shouldReceive('whereIn')->once()->with('group', ['one', 'two']); - $builder->shouldReceive('first')->once()->andReturn(['id' => 1, 'name' => 'taylor']); + $builder->shouldReceive('first')->once()->andReturn((object) ['id' => 1, 'name' => 'taylor']); $conn = m::mock(ConnectionInterface::class); $conn->shouldReceive('table')->once()->with('foo')->andReturn($builder); $hasher = m::mock(Hasher::class); @@ -69,7 +69,7 @@ public function testRetrieveByCredentialsAcceptsCallback() $builder = m::mock(Builder::class); $builder->shouldReceive('where')->once()->with('username', 'dayle'); $builder->shouldReceive('whereIn')->once()->with('group', ['one', 'two']); - $builder->shouldReceive('first')->once()->andReturn(['id' => 1, 'name' => 'taylor']); + $builder->shouldReceive('first')->once()->andReturn((object) ['id' => 1, 'name' => 'taylor']); $conn = m::mock(ConnectionInterface::class); $conn->shouldReceive('table')->once()->with('foo')->andReturn($builder); $hasher = m::mock(Hasher::class); diff --git a/tests/Auth/AuthEloquentUserProviderTest.php b/tests/Auth/AuthEloquentUserProviderTest.php index 402e0b4ae..42f1a4962 100644 --- a/tests/Auth/AuthEloquentUserProviderTest.php +++ b/tests/Auth/AuthEloquentUserProviderTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Auth; -use Hyperf\Database\Model\Builder; -use Hyperf\Database\Model\Model; use Hypervel\Auth\Authenticatable as AuthenticatableUser; -use Hypervel\Auth\Contracts\Authenticatable; use Hypervel\Auth\Providers\EloquentUserProvider; -use Hypervel\Hashing\Contracts\Hasher; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Hashing\Hasher; +use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Model; use Hypervel\Tests\TestCase; use Mockery as m; diff --git a/tests/Auth/AuthMangerTest.php b/tests/Auth/AuthMangerTest.php index 284a53161..f41c0cc79 100644 --- a/tests/Auth/AuthMangerTest.php +++ b/tests/Auth/AuthMangerTest.php @@ -8,20 +8,20 @@ use Hyperf\Context\Context; use Hyperf\Contract\ConfigInterface; use Hyperf\Coroutine\Coroutine; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; use Hyperf\HttpServer\Contract\RequestInterface; use Hypervel\Auth\AuthManager; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Guard; -use Hypervel\Auth\Contracts\UserProvider; use Hypervel\Auth\Guards\RequestGuard; use Hypervel\Auth\Providers\DatabaseUserProvider; use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\Guard; +use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Hashing\Hasher as HashContract; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; -use Hypervel\Hashing\Contracts\Hasher as HashContract; use Hypervel\Tests\TestCase; use Mockery as m; diff --git a/tests/Auth/Stub/AccessGateTestAuthenticatable.php b/tests/Auth/Stub/AccessGateTestAuthenticatable.php index 17484ac01..1a647a697 100644 --- a/tests/Auth/Stub/AccessGateTestAuthenticatable.php +++ b/tests/Auth/Stub/AccessGateTestAuthenticatable.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Auth\Stub; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; class AccessGateTestAuthenticatable implements Authenticatable { diff --git a/tests/Auth/Stub/AccessGateTestClassForGuest.php b/tests/Auth/Stub/AccessGateTestClassForGuest.php index caa408a19..56aa4e39d 100644 --- a/tests/Auth/Stub/AccessGateTestClassForGuest.php +++ b/tests/Auth/Stub/AccessGateTestClassForGuest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Auth\Stub; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; class AccessGateTestClassForGuest { diff --git a/tests/Auth/Stub/AccessGateTestGuestNullableInvokable.php b/tests/Auth/Stub/AccessGateTestGuestNullableInvokable.php index 51d7e2fb7..5d4acfd99 100644 --- a/tests/Auth/Stub/AccessGateTestGuestNullableInvokable.php +++ b/tests/Auth/Stub/AccessGateTestGuestNullableInvokable.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Auth\Stub; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; class AccessGateTestGuestNullableInvokable { diff --git a/tests/Auth/Stub/AccessGateTestPolicy.php b/tests/Auth/Stub/AccessGateTestPolicy.php index e22e3f303..22a04ca84 100644 --- a/tests/Auth/Stub/AccessGateTestPolicy.php +++ b/tests/Auth/Stub/AccessGateTestPolicy.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Auth\Stub; use Hypervel\Auth\Access\HandlesAuthorization; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; class AccessGateTestPolicy { diff --git a/tests/Auth/Stub/AccessGateTestPolicyThatAllowsGuests.php b/tests/Auth/Stub/AccessGateTestPolicyThatAllowsGuests.php index 4a6a5f4ec..24c73e2ad 100644 --- a/tests/Auth/Stub/AccessGateTestPolicyThatAllowsGuests.php +++ b/tests/Auth/Stub/AccessGateTestPolicyThatAllowsGuests.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Auth\Stub; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; class AccessGateTestPolicyThatAllowsGuests { diff --git a/tests/Auth/Stub/AccessGateTestPolicyWithNonGuestBefore.php b/tests/Auth/Stub/AccessGateTestPolicyWithNonGuestBefore.php index 55aac42b7..3de26364c 100644 --- a/tests/Auth/Stub/AccessGateTestPolicyWithNonGuestBefore.php +++ b/tests/Auth/Stub/AccessGateTestPolicyWithNonGuestBefore.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Auth\Stub; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; class AccessGateTestPolicyWithNonGuestBefore { diff --git a/tests/Auth/Stub/AuthorizableStub.php b/tests/Auth/Stub/AuthorizableStub.php index 00b6cb930..4409c84ce 100644 --- a/tests/Auth/Stub/AuthorizableStub.php +++ b/tests/Auth/Stub/AuthorizableStub.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Auth\Stub; -use Hyperf\Database\Model\Model; use Hypervel\Auth\Access\Authorizable; use Hypervel\Auth\Authenticatable; -use Hypervel\Auth\Contracts\Authenticatable as AuthenticatableContract; -use Hypervel\Auth\Contracts\Authorizable as AuthorizableContract; +use Hypervel\Contracts\Auth\Access\Authorizable as AuthorizableContract; +use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract; +use Hypervel\Database\Eloquent\Model; class AuthorizableStub extends Model implements AuthenticatableContract, AuthorizableContract { diff --git a/tests/Broadcasting/AblyBroadcasterTest.php b/tests/Broadcasting/AblyBroadcasterTest.php index a3a7ba6fd..00546128c 100644 --- a/tests/Broadcasting/AblyBroadcasterTest.php +++ b/tests/Broadcasting/AblyBroadcasterTest.php @@ -37,8 +37,6 @@ protected function setUp(): void protected function tearDown(): void { parent::tearDown(); - - m::close(); } public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue() diff --git a/tests/Broadcasting/BroadcastEventTest.php b/tests/Broadcasting/BroadcastEventTest.php index da279961c..ba23d2b1e 100644 --- a/tests/Broadcasting/BroadcastEventTest.php +++ b/tests/Broadcasting/BroadcastEventTest.php @@ -5,9 +5,9 @@ namespace Hypervel\Tests\Broadcasting; use Hypervel\Broadcasting\BroadcastEvent; -use Hypervel\Broadcasting\Contracts\Broadcaster; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastingFactory; use Hypervel\Broadcasting\InteractsWithBroadcasting; +use Hypervel\Contracts\Broadcasting\Broadcaster; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -17,11 +17,6 @@ */ class BroadcastEventTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testBasicEventBroadcastParameterFormatting() { $broadcaster = m::mock(Broadcaster::class); diff --git a/tests/Broadcasting/BroadcastManagerTest.php b/tests/Broadcasting/BroadcastManagerTest.php index d32456a5d..ea8c8298a 100644 --- a/tests/Broadcasting/BroadcastManagerTest.php +++ b/tests/Broadcasting/BroadcastManagerTest.php @@ -9,20 +9,20 @@ use Hypervel\Broadcasting\BroadcastEvent; use Hypervel\Broadcasting\BroadcastManager; use Hypervel\Broadcasting\Channel; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastingFactoryContract; -use Hypervel\Broadcasting\Contracts\ShouldBeUnique; -use Hypervel\Broadcasting\Contracts\ShouldBroadcast; -use Hypervel\Broadcasting\Contracts\ShouldBroadcastNow; use Hypervel\Broadcasting\UniqueBroadcastEvent; -use Hypervel\Bus\Contracts\Dispatcher as BusDispatcherContract; -use Hypervel\Bus\Contracts\QueueingDispatcher; -use Hypervel\Cache\Contracts\Factory as Cache; use Hypervel\Container\DefinitionSource; use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactoryContract; +use Hypervel\Contracts\Broadcasting\ShouldBeUnique; +use Hypervel\Contracts\Broadcasting\ShouldBroadcast; +use Hypervel\Contracts\Broadcasting\ShouldBroadcastNow; +use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Hypervel\Contracts\Bus\QueueingDispatcher; +use Hypervel\Contracts\Cache\Factory as Cache; +use Hypervel\Contracts\Queue\Factory as QueueFactoryContract; use Hypervel\Foundation\Application; use Hypervel\Foundation\Http\Kernel; use Hypervel\Foundation\Http\Middleware\VerifyCsrfToken; -use Hypervel\Queue\Contracts\Factory as QueueFactoryContract; use Hypervel\Support\Facades\Broadcast; use Hypervel\Support\Facades\Bus; use Hypervel\Support\Facades\Facade; @@ -61,8 +61,6 @@ protected function tearDown(): void { parent::tearDown(); - m::close(); - Facade::clearResolvedInstances(); } diff --git a/tests/Broadcasting/BroadcasterTest.php b/tests/Broadcasting/BroadcasterTest.php index 9f31f70b1..09c3ac780 100644 --- a/tests/Broadcasting/BroadcasterTest.php +++ b/tests/Broadcasting/BroadcasterTest.php @@ -6,14 +6,13 @@ use Exception; use Hyperf\Context\RequestContext; -use Hyperf\Database\Model\Booted; use Hyperf\HttpMessage\Server\Request as ServerRequest; use Hyperf\HttpServer\Contract\RequestInterface; use Hyperf\HttpServer\Request; use Hypervel\Auth\AuthManager; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Guard; use Hypervel\Broadcasting\Broadcasters\Broadcaster; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\Guard; use Hypervel\Database\Eloquent\Model; use Hypervel\HttpMessage\Exceptions\HttpException; use Mockery as m; @@ -44,24 +43,28 @@ protected function tearDown(): void { parent::tearDown(); - m::close(); - FakeBroadcaster::flushChannels(); } public function testExtractingParametersWhileCheckingForUserAccess() { - Booted::$container[BroadcasterTestEloquentModelStub::class] = true; - $callback = function ($user, BroadcasterTestEloquentModelStub $model, $nonModel) { }; $parameters = $this->broadcaster->extractAuthParameters('asd.{model}.{nonModel}', 'asd.1.something', $callback); - $this->assertEquals(['model.1.instance', 'something'], $parameters); + $this->assertCount(2, $parameters); + $this->assertInstanceOf(BroadcasterTestEloquentModelStub::class, $parameters[0]); + $this->assertSame('1', $parameters[0]->boundValue); + $this->assertSame('something', $parameters[1]); $callback = function ($user, BroadcasterTestEloquentModelStub $model, BroadcasterTestEloquentModelStub $model2, $something) { }; $parameters = $this->broadcaster->extractAuthParameters('asd.{model}.{model2}.{nonModel}', 'asd.1.uid.something', $callback); - $this->assertEquals(['model.1.instance', 'model.uid.instance', 'something'], $parameters); + $this->assertCount(3, $parameters); + $this->assertInstanceOf(BroadcasterTestEloquentModelStub::class, $parameters[0]); + $this->assertSame('1', $parameters[0]->boundValue); + $this->assertInstanceOf(BroadcasterTestEloquentModelStub::class, $parameters[1]); + $this->assertSame('uid', $parameters[1]->boundValue); + $this->assertSame('something', $parameters[2]); $callback = function ($user) { }; @@ -77,7 +80,10 @@ public function testExtractingParametersWhileCheckingForUserAccess() public function testCanUseChannelClasses() { $parameters = $this->broadcaster->extractAuthParameters('asd.{model}.{nonModel}', 'asd.1.something', DummyBroadcastingChannel::class); - $this->assertEquals(['model.1.instance', 'something'], $parameters); + $this->assertCount(2, $parameters); + $this->assertInstanceOf(BroadcasterTestEloquentModelStub::class, $parameters[0]); + $this->assertSame('1', $parameters[0]->boundValue); + $this->assertSame('something', $parameters[1]); } public function testUnknownChannelAuthHandlerTypeThrowsException() @@ -97,8 +103,6 @@ public function testCanRegisterChannelsAsClasses() public function testNotFoundThrowsHttpException() { - Booted::$container[BroadcasterTestEloquentModelNotFoundStub::class] = true; - $this->expectException(HttpException::class); $callback = function ($user, BroadcasterTestEloquentModelNotFoundStub $model) { @@ -445,40 +449,32 @@ public function channelNameMatchesPattern(string $channel, string $pattern): boo class BroadcasterTestEloquentModelStub extends Model { - public function getRouteKeyName() + public string $boundValue = ''; + + public function getRouteKeyName(): string { return 'id'; } - public function where($key, $value) + public function resolveRouteBinding(mixed $value, ?string $field = null): ?self { - $this->value = $value; - - return $this; - } + $instance = new static(); + $instance->boundValue = (string) $value; - public function firstOrFail() - { - return "model.{$this->value}.instance"; + return $instance; } } class BroadcasterTestEloquentModelNotFoundStub extends Model { - public function getRouteKeyName() + public function getRouteKeyName(): string { return 'id'; } - public function where($key, $value) - { - $this->value = $value; - - return $this; - } - - public function firstOrFail() + public function resolveRouteBinding(mixed $value, ?string $field = null): ?self { + return null; } } diff --git a/tests/Broadcasting/InteractsWithBroadcastingTest.php b/tests/Broadcasting/InteractsWithBroadcastingTest.php index d0bdc0483..6f718194d 100644 --- a/tests/Broadcasting/InteractsWithBroadcastingTest.php +++ b/tests/Broadcasting/InteractsWithBroadcastingTest.php @@ -6,8 +6,8 @@ use Hypervel\Broadcasting\BroadcastEvent; use Hypervel\Broadcasting\Channel; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastingFactory; use Hypervel\Broadcasting\InteractsWithBroadcasting; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; use Mockery as m; use PHPUnit\Framework\TestCase; use TypeError; @@ -38,7 +38,6 @@ class InteractsWithBroadcastingTest extends TestCase { protected function tearDown(): void { - m::close(); parent::tearDown(); } diff --git a/tests/Broadcasting/PendingBroadcastTest.php b/tests/Broadcasting/PendingBroadcastTest.php index ddaccf978..7c3aba304 100644 --- a/tests/Broadcasting/PendingBroadcastTest.php +++ b/tests/Broadcasting/PendingBroadcastTest.php @@ -6,9 +6,9 @@ use Hypervel\Broadcasting\BroadcastEvent; use Hypervel\Broadcasting\Channel; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastingFactory; use Hypervel\Broadcasting\InteractsWithBroadcasting; use Hypervel\Broadcasting\PendingBroadcast; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; @@ -38,11 +38,6 @@ enum PendingBroadcastTestConnectionUnitEnum */ class PendingBroadcastTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testViaAcceptsStringBackedEnum(): void { $dispatcher = m::mock(EventDispatcherInterface::class); diff --git a/tests/Broadcasting/PusherBroadcasterTest.php b/tests/Broadcasting/PusherBroadcasterTest.php index 2df85a33b..063416e87 100644 --- a/tests/Broadcasting/PusherBroadcasterTest.php +++ b/tests/Broadcasting/PusherBroadcasterTest.php @@ -37,8 +37,6 @@ protected function setUp(): void protected function tearDown(): void { parent::tearDown(); - - m::close(); } public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue() diff --git a/tests/Broadcasting/RedisBroadcasterTest.php b/tests/Broadcasting/RedisBroadcasterTest.php index 431242928..fa95dab97 100644 --- a/tests/Broadcasting/RedisBroadcasterTest.php +++ b/tests/Broadcasting/RedisBroadcasterTest.php @@ -40,8 +40,6 @@ protected function tearDown(): void { parent::tearDown(); - m::close(); - Facade::clearResolvedInstances(); } diff --git a/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php index c4aa4803e..159281ac4 100644 --- a/tests/Bus/BusBatchTest.php +++ b/tests/Bus/BusBatchTest.php @@ -5,10 +5,6 @@ namespace Hypervel\Tests\Bus; use Carbon\CarbonImmutable; -use Hyperf\Collection\Collection; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Builder; use Hypervel\Bus\Batch; use Hypervel\Bus\Batchable; use Hypervel\Bus\BatchFactory; @@ -16,11 +12,15 @@ use Hypervel\Bus\Dispatchable; use Hypervel\Bus\PendingBatch; use Hypervel\Bus\Queueable; +use Hypervel\Contracts\Queue\Factory; +use Hypervel\Contracts\Queue\Queue; +use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Queue\CallQueuedClosure; -use Hypervel\Queue\Contracts\Factory; -use Hypervel\Queue\Contracts\Queue; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Support\Collection; use Hypervel\Testbench\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -64,8 +64,6 @@ protected function tearDown(): void parent::tearDown(); unset($_SERVER['__finally.batch'], $_SERVER['__progress.batch'], $_SERVER['__then.batch'], $_SERVER['__catch.batch'], $_SERVER['__catch.exception']); - - m::close(); } public function testJobsCanBeAddedToTheBatch() diff --git a/tests/Bus/BusBatchableTest.php b/tests/Bus/BusBatchableTest.php index 359d130f2..90b9c7b1a 100644 --- a/tests/Bus/BusBatchableTest.php +++ b/tests/Bus/BusBatchableTest.php @@ -9,7 +9,7 @@ use Hyperf\Di\Definition\DefinitionSource; use Hypervel\Bus\Batch; use Hypervel\Bus\Batchable; -use Hypervel\Bus\Contracts\BatchRepository; +use Hypervel\Contracts\Bus\BatchRepository; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -19,11 +19,6 @@ */ class BusBatchableTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testBatchMayBeRetrieved() { $class = new class { diff --git a/tests/Bus/BusDispatcherTest.php b/tests/Bus/BusDispatcherTest.php index 47c8d4a67..798bb130a 100644 --- a/tests/Bus/BusDispatcherTest.php +++ b/tests/Bus/BusDispatcherTest.php @@ -6,9 +6,9 @@ use Hypervel\Bus\Dispatcher; use Hypervel\Bus\Queueable; -use Hypervel\Container\Contracts\Container; -use Hypervel\Queue\Contracts\Queue; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Queue\Queue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\InteractsWithQueue; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -21,11 +21,6 @@ */ class BusDispatcherTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testCommandsThatShouldQueueIsQueued() { $container = m::mock(ContainerInterface::class); diff --git a/tests/Bus/BusPendingBatchTest.php b/tests/Bus/BusPendingBatchTest.php index 7f0eb8aca..7b45f12e7 100644 --- a/tests/Bus/BusPendingBatchTest.php +++ b/tests/Bus/BusPendingBatchTest.php @@ -4,13 +4,13 @@ namespace Hypervel\Tests\Bus; -use Hyperf\Collection\Collection; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; use Hypervel\Bus\Batch; use Hypervel\Bus\Batchable; -use Hypervel\Bus\Contracts\BatchRepository; use Hypervel\Bus\PendingBatch; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Support\Collection; use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; @@ -40,11 +40,6 @@ enum PendingBatchTestConnectionIntEnum: int */ class BusPendingBatchTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testPendingBatchMayBeConfiguredAndDispatched() { $container = $this->getContainer(); diff --git a/tests/Bus/migrations/2024_11_20_000000_create_job_batches_table.php b/tests/Bus/migrations/2024_11_20_000000_create_job_batches_table.php index ce5d54700..ab3b1c0cb 100644 --- a/tests/Bus/migrations/2024_11_20_000000_create_job_batches_table.php +++ b/tests/Bus/migrations/2024_11_20_000000_create_job_batches_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration { diff --git a/tests/Cache/CacheArrayStoreTest.php b/tests/Cache/CacheArrayStoreTest.php index e170a5249..c18811f1e 100644 --- a/tests/Cache/CacheArrayStoreTest.php +++ b/tests/Cache/CacheArrayStoreTest.php @@ -6,7 +6,7 @@ use Carbon\Carbon; use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\Contracts\RefreshableLock; +use Hypervel\Contracts\Cache\RefreshableLock; use Hypervel\Tests\TestCase; use InvalidArgumentException; use stdClass; diff --git a/tests/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php index 481db45a5..450bf2172 100644 --- a/tests/Cache/CacheDatabaseLockTest.php +++ b/tests/Cache/CacheDatabaseLockTest.php @@ -6,12 +6,12 @@ use Carbon\Carbon; use Exception; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Exception\QueryException; -use Hyperf\Database\Query\Builder; -use Hypervel\Cache\Contracts\RefreshableLock; use Hypervel\Cache\DatabaseLock; +use Hypervel\Contracts\Cache\RefreshableLock; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; +use Hypervel\Database\QueryException; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; @@ -42,7 +42,7 @@ public function testLockCanBeAcquiredIfAlreadyOwnedBySameOwner() $owner = $lock->owner(); // First attempt throws exception (key exists) - $table->shouldReceive('insert')->once()->andThrow(new QueryException('', [], new Exception())); + $table->shouldReceive('insert')->once()->andThrow(new QueryException('', '', [], new Exception())); // So it tries to update $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); @@ -67,7 +67,7 @@ public function testLockCannotBeAcquiredIfAlreadyHeld() [$lock, $table] = $this->getLock(); // Insert fails - $table->shouldReceive('insert')->once()->andThrow(new QueryException('', [], new Exception())); + $table->shouldReceive('insert')->once()->andThrow(new QueryException('', '', [], new Exception())); // Update fails too (someone else owns it) $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); diff --git a/tests/Cache/CacheDatabaseStoreTest.php b/tests/Cache/CacheDatabaseStoreTest.php index 6a32c8173..91b6043a7 100644 --- a/tests/Cache/CacheDatabaseStoreTest.php +++ b/tests/Cache/CacheDatabaseStoreTest.php @@ -5,11 +5,11 @@ namespace Hypervel\Tests\Cache; use Carbon\Carbon; -use Hyperf\Collection\Collection; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Builder; use Hypervel\Cache\DatabaseStore; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; +use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; use Mockery as m; diff --git a/tests/Cache/CacheEventsTest.php b/tests/Cache/CacheEventsTest.php index 9e3ff242b..591c579f1 100644 --- a/tests/Cache/CacheEventsTest.php +++ b/tests/Cache/CacheEventsTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Cache; use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\ForgettingKey; @@ -15,6 +14,7 @@ use Hypervel\Cache\Events\RetrievingKey; use Hypervel\Cache\Events\WritingKey; use Hypervel\Cache\Repository; +use Hypervel\Contracts\Cache\Store; use Hypervel\Tests\TestCase; use Mockery as m; use Psr\EventDispatcher\EventDispatcherInterface as Dispatcher; diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 3c1f169bc..a337a3934 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -127,7 +127,6 @@ public function testStoreItemProperlySetsPermissions() $this->assertTrue($result); $result = $store->put('foo', 'baz', 10); $this->assertTrue($result); - m::close(); } public function testStoreItemDirectoryProperlySetsPermissions() @@ -152,7 +151,6 @@ public function testStoreItemDirectoryProperlySetsPermissions() $result = $store->put('foo', 'foo', 10); $this->assertTrue($result); - m::close(); } public function testForeversAreStoredWithHighTimestamp() diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php index 18349bf48..c4d936b48 100644 --- a/tests/Cache/CacheManagerTest.php +++ b/tests/Cache/CacheManagerTest.php @@ -7,8 +7,8 @@ use Hyperf\Config\Config; use Hyperf\Contract\ConfigInterface; use Hypervel\Cache\CacheManager; -use Hypervel\Cache\Contracts\Repository; use Hypervel\Cache\NullStore; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; diff --git a/tests/Cache/CacheNoLockTest.php b/tests/Cache/CacheNoLockTest.php index c205e72eb..09aab2d07 100644 --- a/tests/Cache/CacheNoLockTest.php +++ b/tests/Cache/CacheNoLockTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Cache; -use Hypervel\Cache\Contracts\RefreshableLock; use Hypervel\Cache\NoLock; +use Hypervel\Contracts\Cache\RefreshableLock; use Hypervel\Tests\TestCase; use InvalidArgumentException; diff --git a/tests/Cache/CacheRateLimiterTest.php b/tests/Cache/CacheRateLimiterTest.php index 5838059d9..c76e34ee0 100644 --- a/tests/Cache/CacheRateLimiterTest.php +++ b/tests/Cache/CacheRateLimiterTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Cache; -use Hypervel\Cache\Contracts\Factory as Cache; use Hypervel\Cache\RateLimiter; +use Hypervel\Contracts\Cache\Factory as Cache; use Hypervel\Tests\TestCase; use Mockery as m; diff --git a/tests/Cache/CacheRedisLockTest.php b/tests/Cache/CacheRedisLockTest.php index b8035f048..169097684 100644 --- a/tests/Cache/CacheRedisLockTest.php +++ b/tests/Cache/CacheRedisLockTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\Cache; use Hyperf\Redis\Redis; -use Hypervel\Cache\Contracts\RefreshableLock; use Hypervel\Cache\RedisLock; +use Hypervel\Contracts\Cache\RefreshableLock; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; diff --git a/tests/Cache/CacheRepositoryEnumTest.php b/tests/Cache/CacheRepositoryEnumTest.php index c04dbe149..fbfbf5dc3 100644 --- a/tests/Cache/CacheRepositoryEnumTest.php +++ b/tests/Cache/CacheRepositoryEnumTest.php @@ -5,9 +5,9 @@ namespace Hypervel\Tests\Cache; use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Repository; use Hypervel\Cache\TaggedCache; +use Hypervel\Contracts\Cache\Store; use Hypervel\Tests\TestCase; use Mockery as m; use Psr\EventDispatcher\EventDispatcherInterface as Dispatcher; diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index 493c56dd1..6965c9325 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -12,12 +12,12 @@ use DateTimeImmutable; use Hyperf\Support\Filesystem\Filesystem; use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\FileStore; use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; use Hypervel\Cache\TaggableStore; use Hypervel\Cache\TaggedCache; +use Hypervel\Contracts\Cache\Store; use Hypervel\Tests\TestCase; use Mockery as m; use Psr\EventDispatcher\EventDispatcherInterface as Dispatcher; diff --git a/tests/Cache/CacheSwooleStoreTest.php b/tests/Cache/CacheSwooleStoreTest.php index cafb9f3b6..1aa0bd8f7 100644 --- a/tests/Cache/CacheSwooleStoreTest.php +++ b/tests/Cache/CacheSwooleStoreTest.php @@ -5,9 +5,9 @@ namespace Hypervel\Tests\Cache; use Carbon\Carbon; -use Hyperf\Stringable\Str; use Hypervel\Cache\SwooleStore; use Hypervel\Cache\SwooleTableManager; +use Hypervel\Support\Str; use Hypervel\Tests\TestCase; use Mockery as m; use Psr\Container\ContainerInterface; diff --git a/tests/Cache/RateLimiterEnumTest.php b/tests/Cache/RateLimiterEnumTest.php index 6f123a3d2..9e53bc52c 100644 --- a/tests/Cache/RateLimiterEnumTest.php +++ b/tests/Cache/RateLimiterEnumTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Cache; -use Hypervel\Cache\Contracts\Factory as Cache; use Hypervel\Cache\RateLimiter; +use Hypervel\Contracts\Cache\Factory as Cache; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; diff --git a/tests/Console/Scheduling/CacheEventMutexTest.php b/tests/Console/Scheduling/CacheEventMutexTest.php index b124ff5f2..a527beadb 100644 --- a/tests/Console/Scheduling/CacheEventMutexTest.php +++ b/tests/Console/Scheduling/CacheEventMutexTest.php @@ -5,11 +5,11 @@ namespace Hypervel\Tests\Console\Scheduling; use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Cache\Contracts\Repository; -use Hypervel\Cache\Contracts\Store; use Hypervel\Console\Scheduling\CacheEventMutex; use Hypervel\Console\Scheduling\Event; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Cache\Repository; +use Hypervel\Contracts\Cache\Store; use Mockery as m; use PHPUnit\Framework\TestCase; diff --git a/tests/Console/Scheduling/CacheSchedulingMutexTest.php b/tests/Console/Scheduling/CacheSchedulingMutexTest.php index 2825c956e..8d824bfb7 100644 --- a/tests/Console/Scheduling/CacheSchedulingMutexTest.php +++ b/tests/Console/Scheduling/CacheSchedulingMutexTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Console\Scheduling; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Cache\Contracts\Repository; use Hypervel\Console\Scheduling\CacheEventMutex; use Hypervel\Console\Scheduling\CacheSchedulingMutex; use Hypervel\Console\Scheduling\Event; +use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Support\Carbon; use Mockery as m; use PHPUnit\Framework\TestCase; diff --git a/tests/Console/Scheduling/EventTest.php b/tests/Console/Scheduling/EventTest.php index 6268b87cb..bf3e248cc 100644 --- a/tests/Console/Scheduling/EventTest.php +++ b/tests/Console/Scheduling/EventTest.php @@ -7,12 +7,12 @@ use DateTimeZone; use Hyperf\Context\ApplicationContext; use Hyperf\Context\Context; -use Hyperf\Stringable\Str; use Hyperf\Support\Filesystem\Filesystem; use Hypervel\Console\Contracts\EventMutex; use Hypervel\Console\Scheduling\Event; -use Hypervel\Container\Contracts\Container; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Container\Container; +use Hypervel\Support\Str; use Hypervel\Tests\Foundation\Concerns\HasMockedApplication; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -58,8 +58,6 @@ protected function setUp(): void protected function tearDown(): void { - m::close(); - parent::tearDown(); } diff --git a/tests/Console/Scheduling/ScheduleTest.php b/tests/Console/Scheduling/ScheduleTest.php index a6803e95f..68d88bc5e 100644 --- a/tests/Console/Scheduling/ScheduleTest.php +++ b/tests/Console/Scheduling/ScheduleTest.php @@ -9,7 +9,7 @@ use Hypervel\Console\Contracts\SchedulingMutex; use Hypervel\Console\Scheduling\Schedule; use Hypervel\Container\Container; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Tests\Foundation\Concerns\HasMockedApplication; use Mockery as m; use Mockery\MockInterface; diff --git a/tests/Cookie/Middleware/AddQueuedCookiesToResponseTest.php b/tests/Cookie/Middleware/AddQueuedCookiesToResponseTest.php index 10e8d934a..07b19fd11 100644 --- a/tests/Cookie/Middleware/AddQueuedCookiesToResponseTest.php +++ b/tests/Cookie/Middleware/AddQueuedCookiesToResponseTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Cookie\Middleware; -use Hypervel\Cookie\Contracts\Cookie as ContractsCookie; +use Hypervel\Contracts\Cookie\Cookie as ContractsCookie; use Hypervel\Cookie\Middleware\AddQueuedCookiesToResponse; use Hypervel\Tests\TestCase; use Mockery as m; diff --git a/tests/Core/Database/Eloquent/Concerns/HasBootableTraitsTest.php b/tests/Core/Database/Eloquent/Concerns/HasBootableTraitsTest.php deleted file mode 100644 index db228a345..000000000 --- a/tests/Core/Database/Eloquent/Concerns/HasBootableTraitsTest.php +++ /dev/null @@ -1,192 +0,0 @@ -assertFalse(BootableTraitsTestModel::$bootCalled); - - // Creating a model triggers boot - new BootableTraitsTestModel(); - - $this->assertTrue(BootableTraitsTestModel::$bootCalled); - } - - public function testConventionalBootMethodStillWorks(): void - { - $this->assertFalse(BootableTraitsTestModel::$conventionalBootCalled); - - new BootableTraitsTestModel(); - - $this->assertTrue(BootableTraitsTestModel::$conventionalBootCalled); - } - - public function testInitializeAttributeAddsMethodToInitializers(): void - { - $this->assertFalse(BootableTraitsTestModel::$initializeCalled); - - // Creating a model triggers initialize - new BootableTraitsTestModel(); - - $this->assertTrue(BootableTraitsTestModel::$initializeCalled); - } - - public function testConventionalInitializeMethodStillWorks(): void - { - $this->assertFalse(BootableTraitsTestModel::$conventionalInitializeCalled); - - new BootableTraitsTestModel(); - - $this->assertTrue(BootableTraitsTestModel::$conventionalInitializeCalled); - } - - public function testBothAttributeAndConventionalMethodsWorkTogether(): void - { - $this->assertFalse(BootableTraitsTestModel::$bootCalled); - $this->assertFalse(BootableTraitsTestModel::$conventionalBootCalled); - $this->assertFalse(BootableTraitsTestModel::$initializeCalled); - $this->assertFalse(BootableTraitsTestModel::$conventionalInitializeCalled); - - new BootableTraitsTestModel(); - - $this->assertTrue(BootableTraitsTestModel::$bootCalled); - $this->assertTrue(BootableTraitsTestModel::$conventionalBootCalled); - $this->assertTrue(BootableTraitsTestModel::$initializeCalled); - $this->assertTrue(BootableTraitsTestModel::$conventionalInitializeCalled); - } - - public function testBootMethodIsOnlyCalledOnce(): void - { - BootableTraitsTestModel::$bootCallCount = 0; - - new BootableTraitsTestModel(); - new BootableTraitsTestModel(); - new BootableTraitsTestModel(); - - // Boot should only be called once regardless of how many instances - $this->assertSame(1, BootableTraitsTestModel::$bootCallCount); - } - - public function testInitializeMethodIsCalledForEachInstance(): void - { - BootableTraitsTestModel::$initializeCallCount = 0; - - new BootableTraitsTestModel(); - new BootableTraitsTestModel(); - new BootableTraitsTestModel(); - - // Initialize should be called for each instance - $this->assertSame(3, BootableTraitsTestModel::$initializeCallCount); - } -} - -// Test trait with #[Boot] attribute method -trait HasCustomBootMethod -{ - #[Boot] - public static function customBootMethod(): void - { - static::$bootCalled = true; - ++static::$bootCallCount; - } -} - -// Test trait with conventional boot method -trait HasConventionalBootMethod -{ - public static function bootHasConventionalBootMethod(): void - { - static::$conventionalBootCalled = true; - } -} - -// Test trait with #[Initialize] attribute method -trait HasCustomInitializeMethod -{ - #[Initialize] - public function customInitializeMethod(): void - { - static::$initializeCalled = true; - ++static::$initializeCallCount; - } -} - -// Test trait with conventional initialize method -trait HasConventionalInitializeMethod -{ - public function initializeHasConventionalInitializeMethod(): void - { - static::$conventionalInitializeCalled = true; - } -} - -class BootableTraitsTestModel extends Model -{ - use HasCustomBootMethod; - use HasConventionalBootMethod; - use HasCustomInitializeMethod; - use HasConventionalInitializeMethod; - - public static bool $bootCalled = false; - - public static bool $conventionalBootCalled = false; - - public static bool $initializeCalled = false; - - public static bool $conventionalInitializeCalled = false; - - public static int $bootCallCount = 0; - - public static int $initializeCallCount = 0; - - protected ?string $table = 'test_models'; -} diff --git a/tests/Core/Database/Eloquent/Concerns/HasLocalScopesTest.php b/tests/Core/Database/Eloquent/Concerns/HasLocalScopesTest.php deleted file mode 100644 index c80aa54b5..000000000 --- a/tests/Core/Database/Eloquent/Concerns/HasLocalScopesTest.php +++ /dev/null @@ -1,233 +0,0 @@ -assertTrue($model->hasNamedScope('active')); - } - - public function testHasNamedScopeReturnsTrueForScopeAttribute(): void - { - $model = new ModelWithScopeAttribute(); - - $this->assertTrue($model->hasNamedScope('verified')); - } - - public function testHasNamedScopeReturnsFalseForNonExistentScope(): void - { - $model = new ModelWithTraditionalScope(); - - $this->assertFalse($model->hasNamedScope('nonExistent')); - } - - public function testHasNamedScopeReturnsFalseForRegularMethodWithoutAttribute(): void - { - $model = new ModelWithRegularMethod(); - - $this->assertFalse($model->hasNamedScope('regularMethod')); - } - - public function testCallNamedScopeCallsTraditionalScopeMethod(): void - { - $model = new ModelWithTraditionalScope(); - $builder = $this->createMock(Builder::class); - - $result = $model->callNamedScope('active', [$builder]); - - $this->assertSame($builder, $result); - } - - public function testCallNamedScopeCallsScopeAttributeMethod(): void - { - $model = new ModelWithScopeAttribute(); - $builder = $this->createMock(Builder::class); - - $result = $model->callNamedScope('verified', [$builder]); - - $this->assertSame($builder, $result); - } - - public function testCallNamedScopePassesParameters(): void - { - $model = new ModelWithParameterizedScope(); - $builder = $this->createMock(Builder::class); - - $result = $model->callNamedScope('ofType', [$builder, 'premium']); - - $this->assertSame('premium', $result); - } - - public function testIsScopeMethodWithAttributeReturnsTrueForAttributedMethod(): void - { - $result = ModelWithScopeAttribute::isScopeMethodWithAttributePublic('verified'); - - $this->assertTrue($result); - } - - public function testIsScopeMethodWithAttributeReturnsFalseForTraditionalScope(): void - { - $result = ModelWithTraditionalScope::isScopeMethodWithAttributePublic('scopeActive'); - - $this->assertFalse($result); - } - - public function testIsScopeMethodWithAttributeReturnsFalseForNonExistentMethod(): void - { - $result = ModelWithScopeAttribute::isScopeMethodWithAttributePublic('nonExistent'); - - $this->assertFalse($result); - } - - public function testIsScopeMethodWithAttributeReturnsFalseForMethodWithoutAttribute(): void - { - $result = ModelWithRegularMethod::isScopeMethodWithAttributePublic('regularMethod'); - - $this->assertFalse($result); - } - - public function testModelHasBothTraditionalAndAttributeScopes(): void - { - $model = new ModelWithBothScopeTypes(); - - $this->assertTrue($model->hasNamedScope('active')); - $this->assertTrue($model->hasNamedScope('verified')); - } - - public function testInheritedScopeAttributeIsRecognized(): void - { - $model = new ChildModelWithInheritedScope(); - - $this->assertTrue($model->hasNamedScope('parentScope')); - } - - public function testChildCanOverrideScopeFromParent(): void - { - $model = new ChildModelWithOverriddenScope(); - $builder = $this->createMock(Builder::class); - - // Should call the child's version which returns 'child' - $result = $model->callNamedScope('sharedScope', [$builder]); - - $this->assertSame('child', $result); - } -} - -// Test models -class ModelWithTraditionalScope extends Model -{ - protected ?string $table = 'test_models'; - - public function scopeActive(Builder $builder): Builder - { - return $builder; - } - - public static function isScopeMethodWithAttributePublic(string $method): bool - { - return static::isScopeMethodWithAttribute($method); - } -} - -class ModelWithScopeAttribute extends Model -{ - protected ?string $table = 'test_models'; - - #[Scope] - protected function verified(Builder $builder): Builder - { - return $builder; - } - - public static function isScopeMethodWithAttributePublic(string $method): bool - { - return static::isScopeMethodWithAttribute($method); - } -} - -class ModelWithParameterizedScope extends Model -{ - protected ?string $table = 'test_models'; - - #[Scope] - protected function ofType(Builder $builder, string $type): string - { - return $type; - } -} - -class ModelWithRegularMethod extends Model -{ - protected ?string $table = 'test_models'; - - public function regularMethod(): string - { - return 'regular'; - } - - public static function isScopeMethodWithAttributePublic(string $method): bool - { - return static::isScopeMethodWithAttribute($method); - } -} - -class ModelWithBothScopeTypes extends Model -{ - protected ?string $table = 'test_models'; - - public function scopeActive(Builder $builder): Builder - { - return $builder; - } - - #[Scope] - protected function verified(Builder $builder): Builder - { - return $builder; - } -} - -class ParentModelWithScopeAttribute extends Model -{ - protected ?string $table = 'test_models'; - - #[Scope] - protected function parentScope(Builder $builder): Builder - { - return $builder; - } - - #[Scope] - protected function sharedScope(Builder $builder): string - { - return 'parent'; - } -} - -class ChildModelWithInheritedScope extends ParentModelWithScopeAttribute -{ -} - -class ChildModelWithOverriddenScope extends ParentModelWithScopeAttribute -{ - #[Scope] - protected function sharedScope(Builder $builder): string - { - return 'child'; - } -} diff --git a/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php b/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php deleted file mode 100644 index 1e6a203a7..000000000 --- a/tests/Core/Database/Eloquent/Concerns/HasObserversTest.php +++ /dev/null @@ -1,429 +0,0 @@ -assertSame([], $result); - } - - public function testResolveObserveAttributesReturnsSingleObserver(): void - { - $result = ModelWithSingleObserver::resolveObserveAttributes(); - - $this->assertSame([SingleObserver::class], $result); - } - - public function testResolveObserveAttributesReturnsMultipleObserversFromArray(): void - { - $result = ModelWithMultipleObserversInArray::resolveObserveAttributes(); - - $this->assertSame([FirstObserver::class, SecondObserver::class], $result); - } - - public function testResolveObserveAttributesReturnsMultipleObserversFromRepeatableAttribute(): void - { - $result = ModelWithRepeatableObservedBy::resolveObserveAttributes(); - - $this->assertSame([FirstObserver::class, SecondObserver::class], $result); - } - - public function testResolveObserveAttributesInheritsFromParentClass(): void - { - $result = ChildModelWithOwnObserver::resolveObserveAttributes(); - - // Parent's observer comes first, then child's - $this->assertSame([ParentObserver::class, ChildObserver::class], $result); - } - - public function testResolveObserveAttributesInheritsFromParentWhenChildHasNoAttributes(): void - { - $result = ChildModelWithoutOwnObserver::resolveObserveAttributes(); - - $this->assertSame([ParentObserver::class], $result); - } - - public function testResolveObserveAttributesInheritsFromGrandparent(): void - { - $result = GrandchildModel::resolveObserveAttributes(); - - // Should have grandparent's, parent's, and own observer - $this->assertSame([ParentObserver::class, MiddleObserver::class, GrandchildObserver::class], $result); - } - - public function testResolveObserveAttributesDoesNotInheritFromModelBaseClass(): void - { - // Models that directly extend Model should not try to resolve - // parent attributes since Model itself has no ObservedBy attribute - $result = ModelWithSingleObserver::resolveObserveAttributes(); - - $this->assertSame([SingleObserver::class], $result); - } - - public function testBootHasObserversRegistersObservers(): void - { - $container = m::mock(ContainerInterface::class); - $container->shouldReceive('get') - ->with(SingleObserver::class) - ->once() - ->andReturn(new SingleObserver()); - - $listener = m::mock(ModelListener::class); - $listener->shouldReceive('getModelEvents') - ->once() - ->andReturn([ - 'created' => Created::class, - 'updated' => Updated::class, - ]); - $listener->shouldReceive('register') - ->once() - ->with(ModelWithSingleObserver::class, 'created', m::type('callable')); - - $manager = new ObserverManager($container, $listener); - - // Simulate what bootHasObservers does - $observers = ModelWithSingleObserver::resolveObserveAttributes(); - foreach ($observers as $observer) { - $manager->register(ModelWithSingleObserver::class, $observer); - } - - $this->assertCount(1, $manager->getObservers(ModelWithSingleObserver::class)); - } - - public function testBootHasObserversDoesNothingWhenNoObservers(): void - { - // This test verifies the empty check in bootHasObservers - $result = ModelWithoutObservedBy::resolveObserveAttributes(); - - $this->assertEmpty($result); - } - - public function testPivotModelSupportsObservedByAttribute(): void - { - $result = PivotWithObserver::resolveObserveAttributes(); - - $this->assertSame([PivotObserver::class], $result); - } - - public function testPivotModelInheritsObserversFromParent(): void - { - $result = ChildPivotWithObserver::resolveObserveAttributes(); - - // Parent's observer comes first, then child's - $this->assertSame([PivotObserver::class, ChildPivotObserver::class], $result); - } - - public function testMorphPivotModelSupportsObservedByAttribute(): void - { - $result = MorphPivotWithObserver::resolveObserveAttributes(); - - $this->assertSame([MorphPivotObserver::class], $result); - } - - public function testResolveObserveAttributesCollectsFromTrait(): void - { - $result = ModelUsingTraitWithObserver::resolveObserveAttributes(); - - $this->assertSame([TraitObserver::class], $result); - } - - public function testResolveObserveAttributesCollectsMultipleObserversFromTrait(): void - { - $result = ModelUsingTraitWithMultipleObservers::resolveObserveAttributes(); - - $this->assertSame([TraitFirstObserver::class, TraitSecondObserver::class], $result); - } - - public function testResolveObserveAttributesCollectsFromMultipleTraits(): void - { - $result = ModelUsingMultipleTraitsWithObservers::resolveObserveAttributes(); - - // Both traits' observers should be collected - $this->assertSame([TraitObserver::class, AnotherTraitObserver::class], $result); - } - - public function testResolveObserveAttributesMergesTraitAndClassObservers(): void - { - $result = ModelWithTraitAndOwnObserver::resolveObserveAttributes(); - - // Trait observers come first, then class observers - $this->assertSame([TraitObserver::class, SingleObserver::class], $result); - } - - public function testResolveObserveAttributesMergesParentTraitAndChildObservers(): void - { - $result = ChildModelWithObserverTraitParent::resolveObserveAttributes(); - - // Parent's trait observer -> child's class observer - $this->assertSame([TraitObserver::class, ChildObserver::class], $result); - } - - public function testResolveObserveAttributesCorrectOrderWithParentTraitsAndChild(): void - { - $result = ChildModelWithAllSources::resolveObserveAttributes(); - - // Order: parent class -> parent trait -> child trait -> child class - // ParentModelWithObserver has ParentObserver - // ChildModelWithAllSources uses TraitWithObserver (TraitObserver) and has ChildObserver - $this->assertSame([ParentObserver::class, TraitObserver::class, ChildObserver::class], $result); - } -} - -// Test observer classes -class SingleObserver -{ - public function created(Model $model): void - { - } -} - -class FirstObserver -{ - public function created(Model $model): void - { - } -} - -class SecondObserver -{ - public function created(Model $model): void - { - } -} - -class ParentObserver -{ - public function created(Model $model): void - { - } -} - -class ChildObserver -{ - public function created(Model $model): void - { - } -} - -class MiddleObserver -{ - public function created(Model $model): void - { - } -} - -class GrandchildObserver -{ - public function created(Model $model): void - { - } -} - -// Test model classes -class ModelWithoutObservedBy extends Model -{ - protected ?string $table = 'test_models'; -} - -#[ObservedBy(SingleObserver::class)] -class ModelWithSingleObserver extends Model -{ - protected ?string $table = 'test_models'; -} - -#[ObservedBy([FirstObserver::class, SecondObserver::class])] -class ModelWithMultipleObserversInArray extends Model -{ - protected ?string $table = 'test_models'; -} - -#[ObservedBy(FirstObserver::class)] -#[ObservedBy(SecondObserver::class)] -class ModelWithRepeatableObservedBy extends Model -{ - protected ?string $table = 'test_models'; -} - -// Inheritance test models -#[ObservedBy(ParentObserver::class)] -class ParentModelWithObserver extends Model -{ - protected ?string $table = 'test_models'; -} - -#[ObservedBy(ChildObserver::class)] -class ChildModelWithOwnObserver extends ParentModelWithObserver -{ -} - -class ChildModelWithoutOwnObserver extends ParentModelWithObserver -{ -} - -#[ObservedBy(MiddleObserver::class)] -class MiddleModel extends ParentModelWithObserver -{ -} - -#[ObservedBy(GrandchildObserver::class)] -class GrandchildModel extends MiddleModel -{ -} - -// Pivot test observers -class PivotObserver -{ - public function created(Pivot $pivot): void - { - } -} - -class ChildPivotObserver -{ - public function created(Pivot $pivot): void - { - } -} - -class MorphPivotObserver -{ - public function created(MorphPivot $pivot): void - { - } -} - -// Pivot test models -#[ObservedBy(PivotObserver::class)] -class PivotWithObserver extends Pivot -{ - protected ?string $table = 'test_pivots'; -} - -#[ObservedBy(ChildPivotObserver::class)] -class ChildPivotWithObserver extends PivotWithObserver -{ -} - -#[ObservedBy(MorphPivotObserver::class)] -class MorphPivotWithObserver extends MorphPivot -{ - protected ?string $table = 'test_morph_pivots'; -} - -// Trait test observers -class TraitObserver -{ - public function created(Model $model): void - { - } -} - -class TraitFirstObserver -{ - public function created(Model $model): void - { - } -} - -class TraitSecondObserver -{ - public function created(Model $model): void - { - } -} - -class AnotherTraitObserver -{ - public function created(Model $model): void - { - } -} - -// Traits with ObservedBy attributes -#[ObservedBy(TraitObserver::class)] -trait TraitWithObserver -{ -} - -#[ObservedBy([TraitFirstObserver::class, TraitSecondObserver::class])] -trait TraitWithMultipleObservers -{ -} - -#[ObservedBy(AnotherTraitObserver::class)] -trait AnotherTraitWithObserver -{ -} - -// Models using traits with observers -class ModelUsingTraitWithObserver extends Model -{ - use TraitWithObserver; - - protected ?string $table = 'test_models'; -} - -class ModelUsingTraitWithMultipleObservers extends Model -{ - use TraitWithMultipleObservers; - - protected ?string $table = 'test_models'; -} - -class ModelUsingMultipleTraitsWithObservers extends Model -{ - use TraitWithObserver; - use AnotherTraitWithObserver; - - protected ?string $table = 'test_models'; -} - -#[ObservedBy(SingleObserver::class)] -class ModelWithTraitAndOwnObserver extends Model -{ - use TraitWithObserver; - - protected ?string $table = 'test_models'; -} - -// Parent model that uses a trait with observer -class ParentModelUsingObserverTrait extends Model -{ - use TraitWithObserver; - - protected ?string $table = 'test_models'; -} - -#[ObservedBy(ChildObserver::class)] -class ChildModelWithObserverTraitParent extends ParentModelUsingObserverTrait -{ -} - -// Child model with parent class observer, own trait, and own observer -#[ObservedBy(ChildObserver::class)] -class ChildModelWithAllSources extends ParentModelWithObserver -{ - use TraitWithObserver; -} diff --git a/tests/Core/Database/Eloquent/ModelEnumTest.php b/tests/Core/Database/Eloquent/ModelEnumTest.php deleted file mode 100644 index ef4ebc2dc..000000000 --- a/tests/Core/Database/Eloquent/ModelEnumTest.php +++ /dev/null @@ -1,80 +0,0 @@ -setConnection(ModelTestStringBackedConnection::Testing); - - $this->assertSame('testing', $model->getConnectionName()); - } - - public function testSetConnectionWithIntBackedEnumThrowsTypeError(): void - { - $model = new ModelEnumTestModel(); - - // Int-backed enum causes TypeError because $connection property is ?string - $this->expectException(TypeError::class); - $model->setConnection(ModelTestIntBackedConnection::Testing); - } - - public function testSetConnectionAcceptsUnitEnum(): void - { - $model = new ModelEnumTestModel(); - $model->setConnection(ModelTestUnitConnection::testing); - - $this->assertSame('testing', $model->getConnectionName()); - } - - public function testSetConnectionAcceptsString(): void - { - $model = new ModelEnumTestModel(); - $model->setConnection('mysql'); - - $this->assertSame('mysql', $model->getConnectionName()); - } - - public function testSetConnectionAcceptsNull(): void - { - $model = new ModelEnumTestModel(); - $model->setConnection(null); - - $this->assertNull($model->getConnectionName()); - } -} - -class ModelEnumTestModel extends Model -{ - protected ?string $table = 'test_models'; -} diff --git a/tests/Core/Database/Eloquent/Relations/MorphPivotEnumTest.php b/tests/Core/Database/Eloquent/Relations/MorphPivotEnumTest.php deleted file mode 100644 index 9729566eb..000000000 --- a/tests/Core/Database/Eloquent/Relations/MorphPivotEnumTest.php +++ /dev/null @@ -1,75 +0,0 @@ -setConnection(MorphPivotTestStringBackedConnection::Testing); - - $this->assertSame('testing', $pivot->getConnectionName()); - } - - public function testSetConnectionWithIntBackedEnumThrowsTypeError(): void - { - $pivot = new MorphPivot(); - - // Int-backed enum causes TypeError because $connection property is ?string - $this->expectException(TypeError::class); - $pivot->setConnection(MorphPivotTestIntBackedConnection::Testing); - } - - public function testSetConnectionAcceptsUnitEnum(): void - { - $pivot = new MorphPivot(); - $pivot->setConnection(MorphPivotTestUnitConnection::testing); - - $this->assertSame('testing', $pivot->getConnectionName()); - } - - public function testSetConnectionAcceptsString(): void - { - $pivot = new MorphPivot(); - $pivot->setConnection('mysql'); - - $this->assertSame('mysql', $pivot->getConnectionName()); - } - - public function testSetConnectionAcceptsNull(): void - { - $pivot = new MorphPivot(); - $pivot->setConnection(null); - - $this->assertNull($pivot->getConnectionName()); - } -} diff --git a/tests/Core/Database/Eloquent/Relations/PivotEnumTest.php b/tests/Core/Database/Eloquent/Relations/PivotEnumTest.php deleted file mode 100644 index 815afdfbb..000000000 --- a/tests/Core/Database/Eloquent/Relations/PivotEnumTest.php +++ /dev/null @@ -1,75 +0,0 @@ -setConnection(PivotTestStringBackedConnection::Testing); - - $this->assertSame('testing', $pivot->getConnectionName()); - } - - public function testSetConnectionWithIntBackedEnumThrowsTypeError(): void - { - $pivot = new Pivot(); - - // Int-backed enum causes TypeError because $connection property is ?string - $this->expectException(TypeError::class); - $pivot->setConnection(PivotTestIntBackedConnection::Testing); - } - - public function testSetConnectionAcceptsUnitEnum(): void - { - $pivot = new Pivot(); - $pivot->setConnection(PivotTestUnitConnection::testing); - - $this->assertSame('testing', $pivot->getConnectionName()); - } - - public function testSetConnectionAcceptsString(): void - { - $pivot = new Pivot(); - $pivot->setConnection('mysql'); - - $this->assertSame('mysql', $pivot->getConnectionName()); - } - - public function testSetConnectionAcceptsNull(): void - { - $pivot = new Pivot(); - $pivot->setConnection(null); - - $this->assertNull($pivot->getConnectionName()); - } -} diff --git a/tests/Core/Database/Query/BuilderTest.php b/tests/Core/Database/Query/BuilderTest.php deleted file mode 100644 index 9fee17618..000000000 --- a/tests/Core/Database/Query/BuilderTest.php +++ /dev/null @@ -1,108 +0,0 @@ -getBuilder(); - - $result = $builder->castBinding(BuilderTestStringEnum::Active); - - $this->assertSame('active', $result); - } - - public function testCastBindingWithIntBackedEnum(): void - { - $builder = $this->getBuilder(); - - $result = $builder->castBinding(BuilderTestIntEnum::Two); - - $this->assertSame(2, $result); - } - - public function testCastBindingWithUnitEnum(): void - { - $builder = $this->getBuilder(); - - $result = $builder->castBinding(BuilderTestUnitEnum::Published); - - // UnitEnum uses ->name via enum_value() - $this->assertSame('Published', $result); - } - - public function testCastBindingWithString(): void - { - $builder = $this->getBuilder(); - - $result = $builder->castBinding('test'); - - $this->assertSame('test', $result); - } - - public function testCastBindingWithInt(): void - { - $builder = $this->getBuilder(); - - $result = $builder->castBinding(42); - - $this->assertSame(42, $result); - } - - public function testCastBindingWithNull(): void - { - $builder = $this->getBuilder(); - - $result = $builder->castBinding(null); - - $this->assertNull($result); - } - - protected function getBuilder(): Builder - { - $grammar = m::mock(\Hyperf\Database\Query\Grammars\Grammar::class); - $processor = m::mock(\Hyperf\Database\Query\Processors\Processor::class); - $connection = m::mock(\Hyperf\Database\ConnectionInterface::class); - - $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); - $connection->shouldReceive('getPostProcessor')->andReturn($processor); - - return new Builder($connection); - } -} diff --git a/tests/Core/EloquentBroadcastingTest.php b/tests/Core/EloquentBroadcastingTest.php index ff5dcd85a..4898b3719 100644 --- a/tests/Core/EloquentBroadcastingTest.php +++ b/tests/Core/EloquentBroadcastingTest.php @@ -5,17 +5,17 @@ namespace Hypervel\Tests\Core; use Closure; -use Hyperf\Collection\Arr; -use Hyperf\Database\Model\Events\Created; -use Hyperf\Database\Model\SoftDeletes; -use Hyperf\Database\Schema\Blueprint; use Hypervel\Broadcasting\BroadcastEvent; -use Hypervel\Broadcasting\Contracts\Broadcaster; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastingFactory; +use Hypervel\Contracts\Broadcasting\Broadcaster; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; use Hypervel\Database\Eloquent\BroadcastableModelEventOccurred; use Hypervel\Database\Eloquent\BroadcastsEvents; +use Hypervel\Database\Eloquent\Events\Created; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Arr; use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Schema; use Hypervel\Testbench\TestCase; diff --git a/tests/Core/ModelListenerTest.php b/tests/Core/ModelListenerTest.php index 8210aab36..dc74e96c2 100644 --- a/tests/Core/ModelListenerTest.php +++ b/tests/Core/ModelListenerTest.php @@ -4,13 +4,15 @@ namespace Hypervel\Tests\Core; -use Hyperf\Database\Model\Events\Created; -use Hyperf\Database\Model\Model; +use Hypervel\Contracts\Event\Dispatcher; +use Hypervel\Database\Eloquent\Events\Created; +use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\ModelListener; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; -use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Container\ContainerInterface; +use stdClass; /** * @internal @@ -18,85 +20,140 @@ */ class ModelListenerTest extends TestCase { - public function testRegisterWithInvalidModel() + public function testRegisterWithInvalidModelClass() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unable to find model class: model'); + $this->expectExceptionMessage('Unable to find model class: NonExistentModel'); $this->getModelListener() - ->register('model', 'event', fn () => true); + ->register('NonExistentModel', 'created', fn () => true); + } + + public function testRegisterWithNonModelClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Class [stdClass] must extend Model.'); + + $this->getModelListener() + ->register(stdClass::class, 'created', fn () => true); } public function testRegisterWithInvalidEvent() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Event [event] is not a valid Eloquent event.'); + $this->expectExceptionMessage('Event [invalid_event] is not a valid Eloquent event.'); $this->getModelListener() - ->register(new ModelUser(), 'event', fn () => true); + ->register(ModelListenerTestUser::class, 'invalid_event', fn () => true); } public function testRegister() { - $dispatcher = m::mock(EventDispatcherInterface::class); + $dispatcher = m::mock(Dispatcher::class); $dispatcher->shouldReceive('listen') ->once() - ->with(Created::class, m::type('callable')); + ->with(Created::class, m::type('array')); - $manager = $this->getModelListener($dispatcher); - $manager->register($user = new ModelUser(), 'created', $callback = fn () => true); + $listener = $this->getModelListener($dispatcher); + $listener->register(ModelListenerTestUser::class, 'created', $callback = fn () => true); $this->assertSame( [$callback], - $manager->getCallbacks($user, 'created') + $listener->getCallbacks(ModelListenerTestUser::class, 'created') ); $this->assertSame( ['created' => [$callback]], - $manager->getCallbacks($user) + $listener->getCallbacks(ModelListenerTestUser::class) ); } public function testClear() { - $dispatcher = m::mock(EventDispatcherInterface::class); + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('listen') + ->once() + ->with(Created::class, m::type('array')); + + $listener = $this->getModelListener($dispatcher); + $listener->register(ModelListenerTestUser::class, 'created', fn () => true); + + $listener->clear(ModelListenerTestUser::class); + + $this->assertSame([], $listener->getCallbacks(ModelListenerTestUser::class)); + } + + public function testClearSpecificEvent() + { + $dispatcher = m::mock(Dispatcher::class); $dispatcher->shouldReceive('listen') ->once() - ->with(Created::class, m::type('callable')); + ->with(Created::class, m::type('array')); - $manager = $this->getModelListener($dispatcher); - $manager->register($user = new ModelUser(), 'created', fn () => true); + $listener = $this->getModelListener($dispatcher); + $listener->register(ModelListenerTestUser::class, 'created', $callback = fn () => true); - $manager->clear($user); + $listener->clear(ModelListenerTestUser::class, 'created'); - $this->assertSame([], $manager->getCallbacks(new ModelUser())); + $this->assertSame([], $listener->getCallbacks(ModelListenerTestUser::class, 'created')); } - public function testHandleEvents() + public function testHandleEvent() { - $dispatcher = m::mock(EventDispatcherInterface::class); + $dispatcher = m::mock(Dispatcher::class); $dispatcher->shouldReceive('listen') ->once() - ->with(Created::class, m::type('callable')); + ->with(Created::class, m::type('array')); - $callbackUser = null; - $manager = $this->getModelListener($dispatcher); - $manager->register($user = new ModelUser(), 'created', function ($user) use (&$callbackUser) { - $callbackUser = $user; + $callbackModel = null; + $listener = $this->getModelListener($dispatcher); + $user = new ModelListenerTestUser(); + + $listener->register(ModelListenerTestUser::class, 'created', function ($model) use (&$callbackModel) { + $callbackModel = $model; }); - $manager->handleEvent(new Created($user)); - $this->assertSame($user, $callbackUser); + $listener->handleEvent(new Created($user)); + + $this->assertSame($user, $callbackModel); + } + + public function testHandleEventReturnsFalseWhenCallbackReturnsFalse() + { + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('listen') + ->once() + ->with(Created::class, m::type('array')); + + $listener = $this->getModelListener($dispatcher); + $user = new ModelListenerTestUser(); + + $listener->register(ModelListenerTestUser::class, 'created', fn () => false); + + $result = $listener->handleEvent(new Created($user)); + + $this->assertFalse($result); + } + + public function testRegisterObserverWithInvalidClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to find observer: NonExistentObserver'); + + $this->getModelListener() + ->registerObserver(ModelListenerTestUser::class, 'NonExistentObserver'); } - protected function getModelListener(?EventDispatcherInterface $dispatcher = null): ModelListener + protected function getModelListener(?Dispatcher $dispatcher = null): ModelListener { return new ModelListener( - $dispatcher ?? m::mock(EventDispatcherInterface::class) + m::mock(ContainerInterface::class), + $dispatcher ?? m::mock(Dispatcher::class) ); } } -class ModelUser extends Model +class ModelListenerTestUser extends Model { + protected ?string $table = 'users'; } diff --git a/tests/Core/ObserverManagerTest.php b/tests/Core/ObserverManagerTest.php deleted file mode 100644 index e095c1a83..000000000 --- a/tests/Core/ObserverManagerTest.php +++ /dev/null @@ -1,83 +0,0 @@ -expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unable to find observer: Observer'); - - $this->getObserverManager() - ->register(ObserverUser::class, 'Observer'); - } - - public function testRegister() - { - $container = m::mock(ContainerInterface::class); - $container->shouldReceive('get') - ->with(UserObserver::class) - ->once() - ->andReturn($userObserver = new UserObserver()); - - $listener = m::mock(ModelListener::class); - $listener->shouldReceive('getModelEvents') - ->once() - ->andReturn([ - 'created' => Created::class, - 'updated' => Updated::class, - ]); - $listener->shouldReceive('register') - ->once() - ->with(ObserverUser::class, 'created', m::type('callable')); - - $manager = $this->getObserverManager($container, $listener); - $manager->register(ObserverUser::class, UserObserver::class); - - $this->assertSame( - [$userObserver], - $manager->getObservers(ObserverUser::class) - ); - - $this->assertSame( - [], - $manager->getObservers(ObserverUser::class, 'updated') - ); - } - - protected function getObserverManager(?ContainerInterface $container = null, ?ModelListener $listener = null): ObserverManager - { - return new ObserverManager( - $container ?? m::mock(ContainerInterface::class), - $listener ?? m::mock(ModelListener::class) - ); - } -} - -class ObserverUser extends Model -{ -} - -class UserObserver -{ - public function created(User $user) - { - } -} diff --git a/tests/Core/Database/Eloquent/Concerns/DateFactoryTest.php b/tests/Database/Eloquent/Concerns/DateFactoryTest.php similarity index 98% rename from tests/Core/Database/Eloquent/Concerns/DateFactoryTest.php rename to tests/Database/Eloquent/Concerns/DateFactoryTest.php index 9f5d34f87..c40e00143 100644 --- a/tests/Core/Database/Eloquent/Concerns/DateFactoryTest.php +++ b/tests/Database/Eloquent/Concerns/DateFactoryTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Concerns; +namespace Hypervel\Tests\Database\Eloquent\Concerns; use Carbon\Carbon; use Carbon\CarbonImmutable; @@ -320,7 +320,7 @@ class DateFactoryTestModel extends Model { protected ?string $table = 'test_models'; - protected array $dates = ['published_at']; + protected array $casts = ['published_at' => 'datetime']; } class DateFactoryDateCastModel extends Model @@ -337,7 +337,7 @@ class DateFactoryMultipleDatesModel extends Model { protected ?string $table = 'test_models'; - protected array $dates = ['published_at']; + protected array $casts = ['published_at' => 'datetime']; } class DateFactoryTestPivot extends Pivot diff --git a/tests/Core/Database/Eloquent/Concerns/HasAttributesTest.php b/tests/Database/Eloquent/Concerns/HasAttributesTest.php similarity index 97% rename from tests/Core/Database/Eloquent/Concerns/HasAttributesTest.php rename to tests/Database/Eloquent/Concerns/HasAttributesTest.php index 8a3b8b8c4..58063795e 100644 --- a/tests/Core/Database/Eloquent/Concerns/HasAttributesTest.php +++ b/tests/Database/Eloquent/Concerns/HasAttributesTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Concerns; +namespace Hypervel\Tests\Database\Eloquent\Concerns; use Hypervel\Database\Eloquent\Concerns\HasUuids; use Hypervel\Database\Eloquent\Model; diff --git a/tests/Core/Database/Eloquent/Concerns/HasGlobalScopesTest.php b/tests/Database/Eloquent/Concerns/HasGlobalScopesTest.php similarity index 78% rename from tests/Core/Database/Eloquent/Concerns/HasGlobalScopesTest.php rename to tests/Database/Eloquent/Concerns/HasGlobalScopesTest.php index 2931c181d..954b0af93 100644 --- a/tests/Core/Database/Eloquent/Concerns/HasGlobalScopesTest.php +++ b/tests/Database/Eloquent/Concerns/HasGlobalScopesTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Concerns; +namespace Hypervel\Tests\Database\Eloquent\Concerns; -use Hyperf\Database\Model\Builder; -use Hyperf\Database\Model\Model as HyperfModel; -use Hyperf\Database\Model\Scope; use Hypervel\Database\Eloquent\Attributes\ScopedBy; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Concerns\HasGlobalScopes; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Model as HyperfModel; use Hypervel\Database\Eloquent\Relations\MorphPivot; use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Database\Eloquent\Scope; use Hypervel\Tests\TestCase; /** @@ -22,9 +22,6 @@ class HasGlobalScopesTest extends TestCase { protected function tearDown(): void { - // Clear global scopes between tests - \Hyperf\Database\Model\GlobalScope::$container = []; - parent::tearDown(); } @@ -56,27 +53,39 @@ public function testResolveGlobalScopeAttributesReturnsMultipleScopesFromRepeata $this->assertSame([ActiveScope::class, TenantScope::class], $result); } - public function testResolveGlobalScopeAttributesInheritsFromParentClass(): void + /** + * Laravel does NOT inherit ScopedBy attributes from parent classes. + * PHP attributes are not inherited by default, and Laravel does not + * implement custom inheritance logic for ScopedBy. + */ + public function testResolveGlobalScopeAttributesDoesNotInheritFromParentClass(): void { $result = ChildModelWithOwnScope::resolveGlobalScopeAttributes(); - // Parent's scope comes first, then child's - $this->assertSame([ParentScope::class, ChildScope::class], $result); + // Only child's scope, NOT parent's - Laravel does not inherit ScopedBy + $this->assertSame([ChildScope::class], $result); } - public function testResolveGlobalScopeAttributesInheritsFromParentWhenChildHasNoAttributes(): void + /** + * Laravel does NOT inherit ScopedBy attributes from parent classes. + */ + public function testResolveGlobalScopeAttributesDoesNotInheritFromParentWhenChildHasNoAttributes(): void { $result = ChildModelWithoutOwnScope::resolveGlobalScopeAttributes(); - $this->assertSame([ParentScope::class], $result); + // Empty - child has no ScopedBy, and parent's is not inherited + $this->assertSame([], $result); } - public function testResolveGlobalScopeAttributesInheritsFromGrandparent(): void + /** + * Laravel does NOT inherit ScopedBy attributes from parent/grandparent classes. + */ + public function testResolveGlobalScopeAttributesDoesNotInheritFromGrandparent(): void { $result = GrandchildModelWithScope::resolveGlobalScopeAttributes(); - // Should have grandparent's, parent's, and own scope - $this->assertSame([ParentScope::class, MiddleScope::class, GrandchildScope::class], $result); + // Only grandchild's own scope, NOT parent's or grandparent's + $this->assertSame([GrandchildScope::class], $result); } public function testResolveGlobalScopeAttributesDoesNotInheritFromModelBaseClass(): void @@ -114,26 +123,32 @@ public function testResolveGlobalScopeAttributesMergesTraitAndClassScopes(): voi { $result = ModelWithTraitAndOwnScope::resolveGlobalScopeAttributes(); - // Trait scopes come first, then class scopes - $this->assertSame([TraitScope::class, ActiveScope::class], $result); + // Class attributes come first, then trait attributes (reflection order) + $this->assertSame([ActiveScope::class, TraitScope::class], $result); } - public function testResolveGlobalScopeAttributesMergesParentTraitAndChildScopes(): void + /** + * Laravel does NOT inherit ScopedBy from parent classes or their traits. + */ + public function testResolveGlobalScopeAttributesDoesNotInheritParentTraitScopes(): void { $result = ChildModelWithTraitParent::resolveGlobalScopeAttributes(); - // Parent's trait scope -> child's class scope - $this->assertSame([TraitScope::class, ChildScope::class], $result); + // Only child's class scope - parent's trait scope is NOT inherited + $this->assertSame([ChildScope::class], $result); } - public function testResolveGlobalScopeAttributesCorrectOrderWithParentTraitsAndChild(): void + /** + * Laravel does NOT inherit ScopedBy from parent classes. + * Only the child's own attributes and traits are resolved. + */ + public function testResolveGlobalScopeAttributesOnlyResolvesOwnScopesNotParent(): void { $result = ChildModelWithAllScopeSources::resolveGlobalScopeAttributes(); - // Order: parent class -> parent trait -> child trait -> child class - // ParentModelWithScope has ParentScope - // ChildModelWithAllScopeSources uses TraitWithScope (TraitScope) and has ChildScope - $this->assertSame([ParentScope::class, TraitScope::class, ChildScope::class], $result); + // Only child's class scope and child's trait scope + // Parent's ParentScope is NOT inherited + $this->assertSame([ChildScope::class, TraitScope::class], $result); } public function testAddGlobalScopesRegistersMultipleScopes(): void @@ -162,12 +177,15 @@ public function testPivotModelSupportsScopedByAttribute(): void $this->assertSame([PivotScope::class], $result); } - public function testPivotModelInheritsScopesFromParent(): void + /** + * Laravel does NOT inherit ScopedBy from parent Pivot classes. + */ + public function testPivotModelDoesNotInheritScopesFromParent(): void { $result = ChildPivotWithScope::resolveGlobalScopeAttributes(); - // Parent's scope comes first, then child's - $this->assertSame([PivotScope::class, ChildPivotScope::class], $result); + // Only child's scope - parent's PivotScope is NOT inherited + $this->assertSame([ChildPivotScope::class], $result); } public function testMorphPivotModelSupportsScopedByAttribute(): void diff --git a/tests/Core/Database/Eloquent/Concerns/HasUlidsTest.php b/tests/Database/Eloquent/Concerns/HasUlidsTest.php similarity index 95% rename from tests/Core/Database/Eloquent/Concerns/HasUlidsTest.php rename to tests/Database/Eloquent/Concerns/HasUlidsTest.php index 1d7c4b55b..4f0885c72 100644 --- a/tests/Core/Database/Eloquent/Concerns/HasUlidsTest.php +++ b/tests/Database/Eloquent/Concerns/HasUlidsTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Concerns; +namespace Hypervel\Tests\Database\Eloquent\Concerns; -use Hyperf\Stringable\Str; use Hypervel\Database\Eloquent\Concerns\HasUlids; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; /** diff --git a/tests/Core/Database/Eloquent/Concerns/HasUuidsTest.php b/tests/Database/Eloquent/Concerns/HasUuidsTest.php similarity index 96% rename from tests/Core/Database/Eloquent/Concerns/HasUuidsTest.php rename to tests/Database/Eloquent/Concerns/HasUuidsTest.php index 8eb60f1ee..42615a736 100644 --- a/tests/Core/Database/Eloquent/Concerns/HasUuidsTest.php +++ b/tests/Database/Eloquent/Concerns/HasUuidsTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Concerns; +namespace Hypervel\Tests\Database\Eloquent\Concerns; -use Hyperf\Stringable\Str; use Hypervel\Database\Eloquent\Concerns\HasUuids; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; /** diff --git a/tests/Core/Database/Eloquent/Concerns/TransformsToResourceTest.php b/tests/Database/Eloquent/Concerns/TransformsToResourceTest.php similarity index 84% rename from tests/Core/Database/Eloquent/Concerns/TransformsToResourceTest.php rename to tests/Database/Eloquent/Concerns/TransformsToResourceTest.php index ee76c2529..2cc9f78f6 100644 --- a/tests/Core/Database/Eloquent/Concerns/TransformsToResourceTest.php +++ b/tests/Database/Eloquent/Concerns/TransformsToResourceTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Concerns; +namespace Hypervel\Tests\Database\Eloquent\Concerns; use Hypervel\Database\Eloquent\Attributes\UseResource; use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Resources\Json\JsonResource; use Hypervel\Testbench\TestCase; -use Hypervel\Tests\Core\Database\Eloquent\Models\TransformsToResourceTestModelInModelsNamespace; +use Hypervel\Tests\Database\Eloquent\Models\TransformsToResourceTestModelInModelsNamespace; use LogicException; /** @@ -29,7 +29,7 @@ public function testToResourceWithExplicitClass(): void public function testToResourceThrowsExceptionWhenResourceCannotBeFound(): void { $this->expectException(LogicException::class); - $this->expectExceptionMessage('Failed to find resource class for model [Hypervel\Tests\Core\Database\Eloquent\Concerns\TransformsToResourceTestModel].'); + $this->expectExceptionMessage('Failed to find resource class for model [Hypervel\Tests\Database\Eloquent\Concerns\TransformsToResourceTestModel].'); $model = new TransformsToResourceTestModel(); $model->toResource(); @@ -58,8 +58,8 @@ public function testGuessResourceNameReturnsCorrectNamesForModelsNamespace(): vo $result = TransformsToResourceTestModelInModelsNamespace::guessResourceName(); $this->assertSame([ - 'Hypervel\Tests\Core\Database\Eloquent\Http\Resources\TransformsToResourceTestModelInModelsNamespaceResource', - 'Hypervel\Tests\Core\Database\Eloquent\Http\Resources\TransformsToResourceTestModelInModelsNamespace', + 'Hypervel\Tests\Database\Eloquent\Http\Resources\TransformsToResourceTestModelInModelsNamespaceResource', + 'Hypervel\Tests\Database\Eloquent\Http\Resources\TransformsToResourceTestModelInModelsNamespace', ], $result); } diff --git a/tests/Core/Database/Eloquent/Concerns/migrations/2025_01_01_000000_create_has_uuids_test_models.php b/tests/Database/Eloquent/Concerns/migrations/2025_01_01_000000_create_has_uuids_test_models.php similarity index 83% rename from tests/Core/Database/Eloquent/Concerns/migrations/2025_01_01_000000_create_has_uuids_test_models.php rename to tests/Database/Eloquent/Concerns/migrations/2025_01_01_000000_create_has_uuids_test_models.php index 591321c53..60c61115a 100644 --- a/tests/Core/Database/Eloquent/Concerns/migrations/2025_01_01_000000_create_has_uuids_test_models.php +++ b/tests/Database/Eloquent/Concerns/migrations/2025_01_01_000000_create_has_uuids_test_models.php @@ -2,9 +2,9 @@ declare(strict_types=1); -use Hyperf\Database\Migrations\Migration; -use Hyperf\Database\Schema\Blueprint; -use Hyperf\Database\Schema\Schema; +use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; +use Hypervel\Support\Facades\Schema; return new class extends Migration { public function up(): void diff --git a/tests/Database/Eloquent/EloquentModelWithoutEventsTest.php b/tests/Database/Eloquent/EloquentModelWithoutEventsTest.php index 3ceed9a3a..1f91281c2 100644 --- a/tests/Database/Eloquent/EloquentModelWithoutEventsTest.php +++ b/tests/Database/Eloquent/EloquentModelWithoutEventsTest.php @@ -5,212 +5,195 @@ namespace Hypervel\Tests\Database\Eloquent; use Hypervel\Context\Context; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Database\Eloquent\Model; -use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; -use Hypervel\Tests\TestCase; -use Mockery as m; -use Psr\EventDispatcher\EventDispatcherInterface; +use Hypervel\Event\NullDispatcher; +use Hypervel\Testbench\TestCase; use RuntimeException; /** + * Tests for Model::withoutEvents() coroutine safety. + * * @internal * @coversNothing */ class EloquentModelWithoutEventsTest extends TestCase { - use RunTestsInCoroutine; + protected function tearDown(): void + { + // Ensure context is clean after each test + Context::destroy('__database.model.eventsDisabled'); + TestModel::unsetEventDispatcher(); + parent::tearDown(); + } - public function testWithoutEventsExecutesCallback() + public function testWithoutEventsExecutesCallback(): void { $callbackExecuted = false; $expectedResult = 'test result'; - $callback = function () use (&$callbackExecuted, $expectedResult) { + $result = TestModel::withoutEvents(function () use (&$callbackExecuted, $expectedResult) { $callbackExecuted = true; - return $expectedResult; - }; - - $result = TestModel::withoutEvents($callback); + }); $this->assertTrue($callbackExecuted); - $this->assertEquals($expectedResult, $result); + $this->assertSame($expectedResult, $result); } - public function testGetWithoutEventContextKeyReturnsCorrectKey() + public function testEventsAreDisabledWithinCallback(): void { - $model = TestModel::withoutEvents(function () { - return new TestModel(); - }); - $expectedKey = '__database.model.without_events.' . TestModel::class; + // Events should be enabled initially + $this->assertFalse(TestModel::eventsDisabled()); - $result = $model->getWithoutEventContextKey(); + TestModel::withoutEvents(function () { + // Events should be disabled within callback + $this->assertTrue(TestModel::eventsDisabled()); + }); - $this->assertEquals($expectedKey, $result); + // Events should be re-enabled after callback + $this->assertFalse(TestModel::eventsDisabled()); } - public function testGetEventDispatcherInCoroutineWithWithoutEventsActive() + public function testWithoutEventsSupportsNesting(): void { - $model = new TestModelWithMockDispatcher(); - $dispatcher = m::mock(EventDispatcherInterface::class); - $model->setMockDispatcher($dispatcher); - - // First, verify normal behavior - $this->assertSame($dispatcher, $model->getEventDispatcher()); - - // Now test within withoutEvents context - TestModelWithMockDispatcher::withoutEvents(function () use ($model) { - // Within this callback, getEventDispatcher should return null - $result = $model->getEventDispatcher(); - $this->assertNull($result); - }); + $this->assertFalse(TestModel::eventsDisabled()); - // After exiting the withoutEvents context, it should return to normal - $this->assertSame($dispatcher, $model->getEventDispatcher()); - } + TestModel::withoutEvents(function () { + $this->assertTrue(TestModel::eventsDisabled()); - public function testWithoutEventsNestedInRealCoroutines() - { - $model = new TestModelWithMockDispatcher(); - $dispatcher = m::mock(EventDispatcherInterface::class); - $model->setMockDispatcher($dispatcher); - - TestModelWithMockDispatcher::withoutEvents( - function () use ($model) { - TestModelWithMockDispatcher::withoutEvents(function () use ($model) { - // Within this nested withoutEvents context, getEventDispatcher should return null - $this->assertNull($model->getEventDispatcher()); - }); - // After exiting the inner withoutEvents context, it should still return null - $this->assertNull($model->getEventDispatcher()); - } - ); - } + TestModel::withoutEvents(function () { + // Still disabled in nested call + $this->assertTrue(TestModel::eventsDisabled()); + }); - public function testWithoutEventsContextIsolationBetweenModels() - { - $model1 = null; - $model2 = new AnotherTestModelWithMockDispatcher(); - $dispatcher1 = m::mock(EventDispatcherInterface::class); - $dispatcher2 = m::mock(EventDispatcherInterface::class); - $model2->setMockDispatcher($dispatcher2); - - TestModelWithMockDispatcher::withoutEvents( - function () use (&$model1, $model2, $dispatcher1, $dispatcher2) { - $model1 = new TestModelWithMockDispatcher(); - $model1->setMockDispatcher($dispatcher1); - // model1 should return null within withoutEvents - $this->assertNull($model1->getEventDispatcher()); - - // model2 should still return its dispatcher (different context key) - $this->assertSame($dispatcher2, $model2->getEventDispatcher()); - } - ); - - // After exiting the withoutEvents context, both models should return their respective dispatchers - $this->assertSame($dispatcher1, $model1->getEventDispatcher()); - $this->assertSame($dispatcher2, $model2->getEventDispatcher()); + // Still disabled after nested call exits + $this->assertTrue(TestModel::eventsDisabled()); + }); + + // Re-enabled after outer call exits + $this->assertFalse(TestModel::eventsDisabled()); } - public function testWithoutEventsHandlesExceptionsInCoroutine() + public function testWithoutEventsRestoresStateAfterException(): void { - $model = new TestModelWithMockDispatcher(); - $dispatcher = m::mock(EventDispatcherInterface::class); - $model->setMockDispatcher($dispatcher); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Coroutine exception'); + $this->assertFalse(TestModel::eventsDisabled()); try { - TestModelWithMockDispatcher::withoutEvents(function () { - throw new RuntimeException('Coroutine exception'); + TestModel::withoutEvents(function () { + $this->assertTrue(TestModel::eventsDisabled()); + throw new RuntimeException('Test exception'); }); - } catch (RuntimeException $e) { - $this->assertSame($dispatcher, $model->getEventDispatcher()); - throw $e; + } catch (RuntimeException) { + // Expected } + + // State should be restored even after exception + $this->assertFalse(TestModel::eventsDisabled()); } - public function testContextBehaviorInCoroutine() + public function testEventsDisabledIsSharedAcrossModelClasses(): void { - $model = new TestModelWithMockDispatcher(); - $dispatcher = m::mock(EventDispatcherInterface::class); - $model->setMockDispatcher($dispatcher); + // withoutEvents on one model class affects all model classes + // because it uses a global context key, not per-model-class + $this->assertFalse(TestModel::eventsDisabled()); + $this->assertFalse(AnotherTestModel::eventsDisabled()); + + TestModel::withoutEvents(function () { + // Both model classes see events as disabled + $this->assertTrue(TestModel::eventsDisabled()); + $this->assertTrue(AnotherTestModel::eventsDisabled()); + }); - $contextKey = $model->getWithoutEventContextKey(); + $this->assertFalse(TestModel::eventsDisabled()); + $this->assertFalse(AnotherTestModel::eventsDisabled()); + } + + public function testContextKeyIsCorrect(): void + { + $contextKey = '__database.model.eventsDisabled'; - // Initially, context should not be set + // Initially not set $this->assertNull(Context::get($contextKey)); - $this->assertSame($dispatcher, $model->getEventDispatcher()); - TestModelWithMockDispatcher::withoutEvents(function () use ($model, $contextKey) { - // Within withoutEvents, context should be set - $this->assertSame(1, Context::get($contextKey)); - $this->assertNull($model->getEventDispatcher()); + TestModel::withoutEvents(function () use ($contextKey) { + // Set to true within callback + $this->assertTrue(Context::get($contextKey)); }); - $this->assertSame($dispatcher, $model->getEventDispatcher()); + // Restored after callback (set back to false, which was the initial state) + $this->assertFalse(Context::get($contextKey)); } -} - -class TestModel extends Model -{ - protected ?string $table = 'test_models'; - public static function getWithoutEventContextKey(): string + public function testWithoutEventsReturnsCallbackResult(): void { - return parent::getWithoutEventContextKey(); - } -} - -class TestModelWithMockDispatcher extends Model -{ - protected ?string $table = 'test_models'; + $result = TestModel::withoutEvents(fn () => 42); + $this->assertSame(42, $result); - private ?EventDispatcherInterface $mockDispatcher = null; + $result = TestModel::withoutEvents(fn () => ['foo' => 'bar']); + $this->assertSame(['foo' => 'bar'], $result); - public function setMockDispatcher(EventDispatcherInterface $dispatcher): void - { - $this->mockDispatcher = $dispatcher; + $result = TestModel::withoutEvents(fn () => null); + $this->assertNull($result); } - public function getEventDispatcher(): ?EventDispatcherInterface + public function testGetEventDispatcherReturnsNullDispatcherWhenEventsDisabled(): void { - if (Context::get($this->getWithoutEventContextKey())) { - return null; - } + $realDispatcher = $this->app->get(Dispatcher::class); + TestModel::setEventDispatcher($realDispatcher); + + // Outside withoutEvents, should return the real dispatcher + $dispatcher = TestModel::getEventDispatcher(); + $this->assertSame($realDispatcher, $dispatcher); + $this->assertNotInstanceOf(NullDispatcher::class, $dispatcher); + + TestModel::withoutEvents(function () use ($realDispatcher) { + // Inside withoutEvents, should return a NullDispatcher + $dispatcher = TestModel::getEventDispatcher(); + $this->assertInstanceOf(NullDispatcher::class, $dispatcher); + $this->assertNotSame($realDispatcher, $dispatcher); + }); - return $this->mockDispatcher; + // After withoutEvents, should return the real dispatcher again + $dispatcher = TestModel::getEventDispatcher(); + $this->assertSame($realDispatcher, $dispatcher); + $this->assertNotInstanceOf(NullDispatcher::class, $dispatcher); } - public static function getWithoutEventContextKey(): string + public function testManualDispatchViaNullDispatcherIsSuppressed(): void { - return parent::getWithoutEventContextKey(); - } -} + $realDispatcher = $this->app->get(Dispatcher::class); + TestModel::setEventDispatcher($realDispatcher); -class AnotherTestModelWithMockDispatcher extends Model -{ - protected ?string $table = 'another_test_models'; + $eventFired = false; + $realDispatcher->listen('test.event', function () use (&$eventFired) { + $eventFired = true; + }); - private ?EventDispatcherInterface $mockDispatcher = null; + // Manual dispatch outside withoutEvents should fire + TestModel::getEventDispatcher()->dispatch('test.event'); + $this->assertTrue($eventFired, 'Event should fire outside withoutEvents'); - public function setMockDispatcher(EventDispatcherInterface $dispatcher): void - { - $this->mockDispatcher = $dispatcher; - } + $eventFired = false; - public function getEventDispatcher(): ?EventDispatcherInterface - { - if (Context::get($this->getWithoutEventContextKey())) { - return null; - } + // Manual dispatch inside withoutEvents should be suppressed + TestModel::withoutEvents(function () { + TestModel::getEventDispatcher()->dispatch('test.event'); + }); + $this->assertFalse($eventFired, 'Event should be suppressed inside withoutEvents'); - return $this->mockDispatcher; + // Manual dispatch after withoutEvents should fire again + TestModel::getEventDispatcher()->dispatch('test.event'); + $this->assertTrue($eventFired, 'Event should fire after withoutEvents'); } +} - public static function getWithoutEventContextKey(): string - { - return parent::getWithoutEventContextKey(); - } +class TestModel extends Model +{ + protected ?string $table = 'test_models'; +} + +class AnotherTestModel extends Model +{ + protected ?string $table = 'another_test_models'; } diff --git a/tests/Core/Database/Eloquent/Factories/FactoryTest.php b/tests/Database/Eloquent/Factories/FactoryTest.php similarity index 92% rename from tests/Core/Database/Eloquent/Factories/FactoryTest.php rename to tests/Database/Eloquent/Factories/FactoryTest.php index f124e3523..531051266 100644 --- a/tests/Core/Database/Eloquent/Factories/FactoryTest.php +++ b/tests/Database/Eloquent/Factories/FactoryTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Factories; +namespace Hypervel\Tests\Database\Eloquent\Factories; use BadMethodCallException; use Carbon\Carbon; -use Hyperf\Database\Model\SoftDeletes; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Eloquent\Attributes\UseFactory; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Factories\CrossJoinSequence; @@ -14,31 +14,11 @@ use Hypervel\Database\Eloquent\Factories\HasFactory; use Hypervel\Database\Eloquent\Factories\Sequence; use Hypervel\Database\Eloquent\Model; -use Hypervel\Foundation\Contracts\Application; +use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Testbench\TestCase; -use Hypervel\Tests\Core\Database\Fixtures\Models\Price; -use Mockery as m; +use Hypervel\Tests\Database\Fixtures\Models\Price; use ReflectionClass; -use TypeError; - -enum FactoryTestStringBackedConnection: string -{ - case Default = 'default'; - case Testing = 'testing'; -} - -enum FactoryTestIntBackedConnection: int -{ - case Default = 1; - case Testing = 2; -} - -enum FactoryTestUnitConnection -{ - case default; - case testing; -} /** * @internal @@ -65,7 +45,6 @@ protected function migrateFreshUsing(): array */ protected function tearDown(): void { - m::close(); Factory::flushState(); parent::tearDown(); @@ -413,7 +392,7 @@ public function testBelongsToManyRelationshipWithExistingModelInstancesUsingArra }) ->create(); FactoryTestUserFactory::times(3) - ->hasAttached($roles->toArray(), ['admin' => 'Y'], 'roles') + ->hasAttached($roles->modelKeys(), ['admin' => 'Y'], 'roles') ->create(); $this->assertCount(3, FactoryTestRole::all()); @@ -558,9 +537,9 @@ public function testResolveNestedModelFactories() public function testResolveNestedModelNameFromFactory() { $application = $this->mock(Application::class); - $application->shouldReceive('getNamespace')->andReturn('Hypervel\Tests\Core\Database\Fixtures\\'); + $application->shouldReceive('getNamespace')->andReturn('Hypervel\Tests\Database\Fixtures\\'); - Factory::useNamespace('Hypervel\Tests\Core\Database\Fixtures\Factories\\'); + Factory::useNamespace('Hypervel\Tests\Database\Fixtures\Factories\\'); $factory = Price::factory(); @@ -867,50 +846,13 @@ public function testFlushStateResetsAllResolvers() // After flush, namespace should be reset $this->assertSame('Database\Factories\\', Factory::$namespace); } - - public function testConnectionAcceptsStringBackedEnum() - { - $factory = FactoryTestUserFactory::new()->connection(FactoryTestStringBackedConnection::Testing); - - $this->assertSame('testing', $factory->getConnectionName()); - } - - public function testConnectionWithIntBackedEnumThrowsTypeError() - { - $factory = FactoryTestUserFactory::new()->connection(FactoryTestIntBackedConnection::Testing); - - // Int-backed enum causes TypeError because getConnectionName() returns ?string - $this->expectException(TypeError::class); - $factory->getConnectionName(); - } - - public function testConnectionAcceptsUnitEnum() - { - $factory = FactoryTestUserFactory::new()->connection(FactoryTestUnitConnection::testing); - - $this->assertSame('testing', $factory->getConnectionName()); - } - - public function testConnectionAcceptsString() - { - $factory = FactoryTestUserFactory::new()->connection('mysql'); - - $this->assertSame('mysql', $factory->getConnectionName()); - } - - public function testGetConnectionNameReturnsNullByDefault() - { - $factory = FactoryTestUserFactory::new(); - - $this->assertNull($factory->getConnectionName()); - } } class FactoryTestUserFactory extends Factory { - protected $model = FactoryTestUser::class; + protected ?string $model = FactoryTestUser::class; - public function definition() + public function definition(): array { return [ 'name' => $this->faker->name(), @@ -945,9 +887,9 @@ public function factoryTestRoles() class FactoryTestPostFactory extends Factory { - protected $model = FactoryTestPost::class; + protected ?string $model = FactoryTestPost::class; - public function definition() + public function definition(): array { return [ 'user_id' => FactoryTestUserFactory::new(), @@ -985,9 +927,9 @@ public function comments() class FactoryTestCommentFactory extends Factory { - protected $model = FactoryTestComment::class; + protected ?string $model = FactoryTestComment::class; - public function definition() + public function definition(): array { return [ 'commentable_id' => FactoryTestPostFactory::new(), @@ -1019,9 +961,9 @@ public function commentable() class FactoryTestRoleFactory extends Factory { - protected $model = FactoryTestRole::class; + protected ?string $model = FactoryTestRole::class; - public function definition() + public function definition(): array { return [ 'name' => $this->faker->name(), @@ -1047,7 +989,7 @@ public function users() class FactoryTestModelWithUseFactoryFactory extends Factory { - public function definition() + public function definition(): array { return [ 'name' => $this->faker->name(), @@ -1068,9 +1010,9 @@ class FactoryTestModelWithUseFactory extends Model // Factory for testing static $factory property precedence class FactoryTestModelWithStaticFactory extends Factory { - protected $model = FactoryTestModelWithStaticFactoryAndAttribute::class; + protected ?string $model = FactoryTestModelWithStaticFactoryAndAttribute::class; - public function definition() + public function definition(): array { return [ 'name' => $this->faker->name(), @@ -1081,7 +1023,7 @@ public function definition() // Alternative factory for the attribute (should NOT be used) class FactoryTestAlternativeFactory extends Factory { - public function definition() + public function definition(): array { return [ 'name' => 'alternative', @@ -1104,7 +1046,7 @@ class FactoryTestModelWithStaticFactoryAndAttribute extends Model // Factory without explicit $model property for testing resolver isolation class FactoryTestFactoryWithoutModel extends Factory { - public function definition() + public function definition(): array { return []; } diff --git a/tests/Core/Database/Eloquent/Factories/migrations/2025_07_24_000000_create_models.php b/tests/Database/Eloquent/Factories/migrations/2025_07_24_000000_create_models.php similarity index 97% rename from tests/Core/Database/Eloquent/Factories/migrations/2025_07_24_000000_create_models.php rename to tests/Database/Eloquent/Factories/migrations/2025_07_24_000000_create_models.php index cde2faef8..0a7d1bae4 100644 --- a/tests/Core/Database/Eloquent/Factories/migrations/2025_07_24_000000_create_models.php +++ b/tests/Database/Eloquent/Factories/migrations/2025_07_24_000000_create_models.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration { diff --git a/tests/Core/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php b/tests/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php similarity index 77% rename from tests/Core/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php rename to tests/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php index c74fd84f9..131d11f4a 100644 --- a/tests/Core/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php +++ b/tests/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Models; +namespace Hypervel\Tests\Database\Eloquent\Models; use Hypervel\Database\Eloquent\Model; diff --git a/tests/Database/Eloquent/NewBaseQueryBuilderTest.php b/tests/Database/Eloquent/NewBaseQueryBuilderTest.php new file mode 100644 index 000000000..6bbd628ab --- /dev/null +++ b/tests/Database/Eloquent/NewBaseQueryBuilderTest.php @@ -0,0 +1,164 @@ +shouldReceive('query')->once()->andReturn($customBuilder); + + $model = new NewBaseQueryBuilderTestModel(); + $model->setTestConnection($connection); + + $builder = $model->testNewBaseQueryBuilder(); + + $this->assertInstanceOf(CustomQueryBuilder::class, $builder); + $this->assertSame($customBuilder, $builder); + } + + public function testPivotUsesConnectionQueryMethod(): void + { + $mockConnection = m::mock(Connection::class); + $customBuilder = new CustomQueryBuilder( + $mockConnection, + new Grammar($mockConnection), + new Processor() + ); + + $connection = m::mock(Connection::class); + $connection->shouldReceive('query')->once()->andReturn($customBuilder); + + $pivot = new NewBaseQueryBuilderTestPivot(); + $pivot->setTestConnection($connection); + + $builder = $pivot->testNewBaseQueryBuilder(); + + $this->assertInstanceOf(CustomQueryBuilder::class, $builder); + $this->assertSame($customBuilder, $builder); + } + + public function testMorphPivotUsesConnectionQueryMethod(): void + { + $mockConnection = m::mock(Connection::class); + $customBuilder = new CustomQueryBuilder( + $mockConnection, + new Grammar($mockConnection), + new Processor() + ); + + $connection = m::mock(Connection::class); + $connection->shouldReceive('query')->once()->andReturn($customBuilder); + + $morphPivot = new NewBaseQueryBuilderTestMorphPivot(); + $morphPivot->setTestConnection($connection); + + $builder = $morphPivot->testNewBaseQueryBuilder(); + + $this->assertInstanceOf(CustomQueryBuilder::class, $builder); + $this->assertSame($customBuilder, $builder); + } +} + +// Test fixtures + +class NewBaseQueryBuilderTestModel extends Model +{ + protected ?string $table = 'test_models'; + + protected ?Connection $testConnection = null; + + public function setTestConnection(Connection $connection): void + { + $this->testConnection = $connection; + } + + public function getConnection(): Connection + { + return $this->testConnection ?? parent::getConnection(); + } + + public function testNewBaseQueryBuilder(): QueryBuilder + { + return $this->newBaseQueryBuilder(); + } +} + +class NewBaseQueryBuilderTestPivot extends Pivot +{ + protected ?string $table = 'test_pivots'; + + protected ?Connection $testConnection = null; + + public function setTestConnection(Connection $connection): void + { + $this->testConnection = $connection; + } + + public function getConnection(): Connection + { + return $this->testConnection ?? parent::getConnection(); + } + + public function testNewBaseQueryBuilder(): QueryBuilder + { + return $this->newBaseQueryBuilder(); + } +} + +class NewBaseQueryBuilderTestMorphPivot extends MorphPivot +{ + protected ?string $table = 'test_morph_pivots'; + + protected ?Connection $testConnection = null; + + public function setTestConnection(Connection $connection): void + { + $this->testConnection = $connection; + } + + public function getConnection(): Connection + { + return $this->testConnection ?? parent::getConnection(); + } + + public function testNewBaseQueryBuilder(): QueryBuilder + { + return $this->newBaseQueryBuilder(); + } +} + +/** + * A custom query builder to verify the connection's builder is used. + */ +class CustomQueryBuilder extends QueryBuilder +{ +} diff --git a/tests/Core/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php b/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php similarity index 95% rename from tests/Core/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php rename to tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php index 888c1ba83..e77bb0b2b 100644 --- a/tests/Core/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php +++ b/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Relations; +namespace Hypervel\Tests\Database\Eloquent\Relations; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; @@ -329,39 +329,39 @@ class PivotEventsTestCollaborator extends Pivot public static array $eventsCalled = []; - protected function boot(): void + protected static function boot(): void { parent::boot(); - static::registerCallback('creating', function ($model) { + static::creating(function ($model) { static::$eventsCalled[] = 'creating'; }); - static::registerCallback('created', function ($model) { + static::created(function ($model) { static::$eventsCalled[] = 'created'; }); - static::registerCallback('updating', function ($model) { + static::updating(function ($model) { static::$eventsCalled[] = 'updating'; }); - static::registerCallback('updated', function ($model) { + static::updated(function ($model) { static::$eventsCalled[] = 'updated'; }); - static::registerCallback('saving', function ($model) { + static::saving(function ($model) { static::$eventsCalled[] = 'saving'; }); - static::registerCallback('saved', function ($model) { + static::saved(function ($model) { static::$eventsCalled[] = 'saved'; }); - static::registerCallback('deleting', function ($model) { + static::deleting(function ($model) { static::$eventsCalled[] = 'deleting'; }); - static::registerCallback('deleted', function ($model) { + static::deleted(function ($model) { static::$eventsCalled[] = 'deleted'; }); } diff --git a/tests/Core/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php b/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php similarity index 95% rename from tests/Core/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php rename to tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php index 451daa43e..800db5f8c 100644 --- a/tests/Core/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php +++ b/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent\Relations; +namespace Hypervel\Tests\Database\Eloquent\Relations; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\MorphPivot; @@ -360,39 +360,39 @@ class MorphPivotEventsTestTaggable extends MorphPivot public static array $eventsCalled = []; - protected function boot(): void + protected static function boot(): void { parent::boot(); - static::registerCallback('creating', function ($model) { + static::creating(function ($model) { static::$eventsCalled[] = 'creating'; }); - static::registerCallback('created', function ($model) { + static::created(function ($model) { static::$eventsCalled[] = 'created'; }); - static::registerCallback('updating', function ($model) { + static::updating(function ($model) { static::$eventsCalled[] = 'updating'; }); - static::registerCallback('updated', function ($model) { + static::updated(function ($model) { static::$eventsCalled[] = 'updated'; }); - static::registerCallback('saving', function ($model) { + static::saving(function ($model) { static::$eventsCalled[] = 'saving'; }); - static::registerCallback('saved', function ($model) { + static::saved(function ($model) { static::$eventsCalled[] = 'saved'; }); - static::registerCallback('deleting', function ($model) { + static::deleting(function ($model) { static::$eventsCalled[] = 'deleting'; }); - static::registerCallback('deleted', function ($model) { + static::deleted(function ($model) { static::$eventsCalled[] = 'deleted'; }); } diff --git a/tests/Core/Database/Eloquent/Relations/migrations/2025_01_01_000000_create_pivot_events_test_tables.php b/tests/Database/Eloquent/Relations/migrations/2025_01_01_000000_create_pivot_events_test_tables.php similarity index 94% rename from tests/Core/Database/Eloquent/Relations/migrations/2025_01_01_000000_create_pivot_events_test_tables.php rename to tests/Database/Eloquent/Relations/migrations/2025_01_01_000000_create_pivot_events_test_tables.php index 12ce24a9e..7baad7928 100644 --- a/tests/Core/Database/Eloquent/Relations/migrations/2025_01_01_000000_create_pivot_events_test_tables.php +++ b/tests/Database/Eloquent/Relations/migrations/2025_01_01_000000_create_pivot_events_test_tables.php @@ -2,9 +2,9 @@ declare(strict_types=1); -use Hyperf\Database\Migrations\Migration; -use Hyperf\Database\Schema\Blueprint; -use Hyperf\Database\Schema\Schema; +use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; +use Hypervel\Support\Facades\Schema; return new class extends Migration { public function up(): void diff --git a/tests/Core/Database/Eloquent/UseEloquentBuilderTest.php b/tests/Database/Eloquent/UseEloquentBuilderTest.php similarity index 91% rename from tests/Core/Database/Eloquent/UseEloquentBuilderTest.php rename to tests/Database/Eloquent/UseEloquentBuilderTest.php index 206bda8cf..b3e687cc9 100644 --- a/tests/Core/Database/Eloquent/UseEloquentBuilderTest.php +++ b/tests/Database/Eloquent/UseEloquentBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Eloquent; +namespace Hypervel\Tests\Database\Eloquent; use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; use Hypervel\Database\Eloquent\Builder; @@ -32,7 +32,7 @@ public function testNewModelBuilderReturnsDefaultBuilderWhenNoAttribute(): void $model = new UseEloquentBuilderTestModel(); $query = m::mock(\Hypervel\Database\Query\Builder::class); - $builder = $model->newModelBuilder($query); + $builder = $model->newEloquentBuilder($query); $this->assertInstanceOf(Builder::class, $builder); $this->assertNotInstanceOf(CustomTestBuilder::class, $builder); @@ -43,7 +43,7 @@ public function testNewModelBuilderReturnsCustomBuilderWhenAttributePresent(): v $model = new UseEloquentBuilderTestModelWithAttribute(); $query = m::mock(\Hypervel\Database\Query\Builder::class); - $builder = $model->newModelBuilder($query); + $builder = $model->newEloquentBuilder($query); $this->assertInstanceOf(CustomTestBuilder::class, $builder); } @@ -55,10 +55,10 @@ public function testNewModelBuilderCachesResolvedBuilderClass(): void $query = m::mock(\Hypervel\Database\Query\Builder::class); // First call should resolve and cache - $builder1 = $model1->newModelBuilder($query); + $builder1 = $model1->newEloquentBuilder($query); // Second call should use cache - $builder2 = $model2->newModelBuilder($query); + $builder2 = $model2->newEloquentBuilder($query); // Both should be CustomTestBuilder $this->assertInstanceOf(CustomTestBuilder::class, $builder1); @@ -89,8 +89,8 @@ public function testDifferentModelsUseDifferentCaches(): void $modelWithAttribute = new UseEloquentBuilderTestModelWithAttribute(); $query = m::mock(\Hypervel\Database\Query\Builder::class); - $builder1 = $modelWithoutAttribute->newModelBuilder($query); - $builder2 = $modelWithAttribute->newModelBuilder($query); + $builder1 = $modelWithoutAttribute->newEloquentBuilder($query); + $builder2 = $modelWithAttribute->newEloquentBuilder($query); $this->assertInstanceOf(Builder::class, $builder1); $this->assertNotInstanceOf(CustomTestBuilder::class, $builder1); @@ -102,7 +102,7 @@ public function testChildModelWithoutAttributeUsesDefaultBuilder(): void $model = new UseEloquentBuilderTestChildModel(); $query = m::mock(\Hypervel\Database\Query\Builder::class); - $builder = $model->newModelBuilder($query); + $builder = $model->newEloquentBuilder($query); // PHP attributes are not inherited - child needs its own attribute $this->assertInstanceOf(Builder::class, $builder); @@ -114,7 +114,7 @@ public function testChildModelWithOwnAttributeUsesOwnBuilder(): void $model = new UseEloquentBuilderTestChildModelWithOwnAttribute(); $query = m::mock(\Hypervel\Database\Query\Builder::class); - $builder = $model->newModelBuilder($query); + $builder = $model->newEloquentBuilder($query); $this->assertInstanceOf(AnotherCustomTestBuilder::class, $builder); } diff --git a/tests/Core/Database/Fixtures/Factories/PriceFactory.php b/tests/Database/Fixtures/Factories/PriceFactory.php similarity index 68% rename from tests/Core/Database/Fixtures/Factories/PriceFactory.php rename to tests/Database/Fixtures/Factories/PriceFactory.php index 9e6547552..a3a679396 100644 --- a/tests/Core/Database/Fixtures/Factories/PriceFactory.php +++ b/tests/Database/Fixtures/Factories/PriceFactory.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Fixtures\Factories; +namespace Hypervel\Tests\Database\Fixtures\Factories; use Hypervel\Database\Eloquent\Factories\Factory; class PriceFactory extends Factory { - public function definition() + public function definition(): array { return [ 'name' => $this->faker->name(), diff --git a/tests/Core/Database/Fixtures/Models/Price.php b/tests/Database/Fixtures/Models/Price.php similarity index 72% rename from tests/Core/Database/Fixtures/Models/Price.php rename to tests/Database/Fixtures/Models/Price.php index afb0df6d0..7cfe534e2 100644 --- a/tests/Core/Database/Fixtures/Models/Price.php +++ b/tests/Database/Fixtures/Models/Price.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core\Database\Fixtures\Models; +namespace Hypervel\Tests\Database\Fixtures\Models; use Hypervel\Database\Eloquent\Factories\HasFactory; use Hypervel\Database\Eloquent\Model; -use Hypervel\Tests\Core\Database\Fixtures\Factories\PriceFactory; +use Hypervel\Tests\Database\Fixtures\Factories\PriceFactory; class Price extends Model { diff --git a/tests/Database/Laravel/DatabaseAbstractSchemaGrammarTest.php b/tests/Database/Laravel/DatabaseAbstractSchemaGrammarTest.php new file mode 100755 index 000000000..35c83bec1 --- /dev/null +++ b/tests/Database/Laravel/DatabaseAbstractSchemaGrammarTest.php @@ -0,0 +1,31 @@ +assertSame('create database "foo"', $grammar->compileCreateDatabase('foo')); + } + + public function testDropDatabaseIfExists() + { + $connection = m::mock(Connection::class); + $grammar = new class($connection) extends Grammar { + }; + + $this->assertSame('drop database if exists "foo"', $grammar->compileDropDatabaseIfExists('foo')); + } +} diff --git a/tests/Database/Laravel/DatabaseConcernsBuildsQueriesTraitTest.php b/tests/Database/Laravel/DatabaseConcernsBuildsQueriesTraitTest.php new file mode 100644 index 000000000..6c6706a3e --- /dev/null +++ b/tests/Database/Laravel/DatabaseConcernsBuildsQueriesTraitTest.php @@ -0,0 +1,23 @@ +tap(function ($builder) use ($mock) { + $this->assertEquals($mock, $builder); + }); + } +} diff --git a/tests/Database/Laravel/DatabaseConcernsHasAttributesTest.php b/tests/Database/Laravel/DatabaseConcernsHasAttributesTest.php new file mode 100644 index 000000000..583bf542e --- /dev/null +++ b/tests/Database/Laravel/DatabaseConcernsHasAttributesTest.php @@ -0,0 +1,122 @@ +getMutatedAttributes(); + $this->assertEquals(['some_attribute'], $attributes); + } + + public function testWithConstructorArguments() + { + $instance = new HasAttributesWithConstructorArguments(null); + $attributes = $instance->getMutatedAttributes(); + $this->assertEquals(['some_attribute'], $attributes); + } + + public function testRelationsToArray() + { + $mock = m::mock(HasAttributesWithoutConstructor::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods() + ->shouldReceive('getArrayableRelations')->andReturn([ + 'arrayable_relation' => Collection::make(['foo' => 'bar']), + 'invalid_relation' => 'invalid', + 'null_relation' => null, + ]) + ->getMock(); + + $this->assertEquals([ + 'arrayable_relation' => ['foo' => 'bar'], + 'null_relation' => null, + ], $mock->relationsToArray()); + } + + public function testCastingEmptyStringToArrayDoesNotError() + { + $instance = new HasAttributesWithArrayCast(); + $this->assertEquals(['foo' => null], $instance->attributesToArray()); + + $this->assertTrue(json_last_error() === JSON_ERROR_NONE); + } + + public function testUnsettingCachedAttribute() + { + $instance = new HasCacheableAttributeWithAccessor(); + $this->assertEquals('foo', $instance->getAttribute('cacheableProperty')); + $this->assertTrue($instance->cachedAttributeIsset('cacheableProperty')); + + unset($instance->cacheableProperty); + + $this->assertFalse($instance->cachedAttributeIsset('cacheableProperty')); + } +} + +class HasAttributesWithoutConstructor +{ + use HasAttributes; + + public function someAttribute(): Attribute + { + return new Attribute(function () { + }); + } +} + +class HasAttributesWithConstructorArguments extends HasAttributesWithoutConstructor +{ + public function __construct($someValue) + { + } +} + +class HasAttributesWithArrayCast +{ + use HasAttributes; + + public function getArrayableAttributes(): array + { + return ['foo' => '']; + } + + public function getCasts(): array + { + return ['foo' => 'array']; + } + + public function usesTimestamps(): bool + { + return false; + } +} + +/** + * @property string $cacheableProperty + */ +class HasCacheableAttributeWithAccessor extends Model +{ + public function cacheableProperty(): Attribute + { + return Attribute::make( + get: fn () => 'foo' + )->shouldCache(); + } + + public function cachedAttributeIsset($attribute): bool + { + return isset($this->attributeCastCache[$attribute]); + } +} diff --git a/tests/Database/Laravel/DatabaseConcernsPreventsCircularRecursionTest.php b/tests/Database/Laravel/DatabaseConcernsPreventsCircularRecursionTest.php new file mode 100644 index 000000000..c36b9b735 --- /dev/null +++ b/tests/Database/Laravel/DatabaseConcernsPreventsCircularRecursionTest.php @@ -0,0 +1,271 @@ +assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + + $this->assertEquals(0, $instance->callStack()); + $this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + + $this->assertEquals(1, $instance->callStack()); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + } + + public function testRecursiveDefaultCallbackIsCalledOnlyOnRecursion() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $instance->defaultStack); + + $this->assertEquals(['instance' => 1, 'default' => 0], $instance->callCallableDefaultStack()); + $this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $instance->defaultStack); + + $this->assertEquals(['instance' => 2, 'default' => 1], $instance->callCallableDefaultStack()); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $instance->defaultStack); + } + + public function testRecursiveDefaultCallbackIsCalledOnlyOncePerCallStack() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $instance->defaultStack); + + $this->assertEquals( + [ + ['instance' => 1, 'default' => 0], + ['instance' => 1, 'default' => 0], + ['instance' => 1, 'default' => 0], + ], + $instance->callCallableDefaultStackRepeatedly(), + ); + $this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $instance->defaultStack); + + $this->assertEquals( + [ + ['instance' => 2, 'default' => 1], + ['instance' => 2, 'default' => 1], + ['instance' => 2, 'default' => 1], + ], + $instance->callCallableDefaultStackRepeatedly(), + ); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $instance->defaultStack); + } + + public function testRecursiveCallsAreLimitedToIndividualInstances() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + $other = $instance->other; + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $instance->callStack(); + $this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $instance->callStack(); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $other->callStack(); + $this->assertEquals(3, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(1, $other->instanceStack); + + $other->callStack(); + $this->assertEquals(4, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $other->instanceStack); + } + + public function testRecursiveCallsToCircularReferenceCallsOtherInstanceOnce() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + $other = $instance->other; + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $instance->callOtherStack(); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $other->instanceStack); + + $instance->callOtherStack(); + $this->assertEquals(4, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $other->instanceStack); + + $other->callOtherStack(); + $this->assertEquals(6, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(3, $other->instanceStack); + $this->assertEquals(3, $instance->instanceStack); + + $other->callOtherStack(); + $this->assertEquals(8, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(4, $other->instanceStack); + $this->assertEquals(4, $instance->instanceStack); + } + + public function testRecursiveCallsToCircularLinkedListCallsEachInstanceOnce() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + $second = $instance->other; + $third = new PreventsCircularRecursionWithRecursiveMethod($second); + $instance->other = $third; + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $second->instanceStack); + $this->assertEquals(0, $third->instanceStack); + + $instance->callOtherStack(); + $this->assertEquals(3, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $second->instanceStack); + $this->assertEquals(1, $third->instanceStack); + + $second->callOtherStack(); + $this->assertEquals(6, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $second->instanceStack); + $this->assertEquals(2, $third->instanceStack); + + $third->callOtherStack(); + $this->assertEquals(9, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(3, $instance->instanceStack); + $this->assertEquals(3, $second->instanceStack); + $this->assertEquals(3, $third->instanceStack); + } + + public function testMockedModelCallToWithoutRecursionMethodWorks(): void + { + $mock = m::mock(TestModel::class)->makePartial(); + + // Model toArray method implementation + $toArray = $mock->withoutRecursion( + fn () => array_merge($mock->attributesToArray(), $mock->relationsToArray()), + fn () => $mock->attributesToArray(), + ); + $this->assertEquals([], $toArray); + } +} + +class PreventsCircularRecursionWithRecursiveMethod +{ + use PreventsCircularRecursion; + + public function __construct( + public ?PreventsCircularRecursionWithRecursiveMethod $other = null, + ) { + $this->other ??= new PreventsCircularRecursionWithRecursiveMethod($this); + } + + public static int $globalStack = 0; + public int $instanceStack = 0; + public int $defaultStack = 0; + + public function callStack(): int + { + return $this->withoutRecursion( + function () { + static::$globalStack++; + $this->instanceStack++; + + return $this->callStack(); + }, + $this->instanceStack, + ); + } + + public function callCallableDefaultStack(): array + { + return $this->withoutRecursion( + function () { + static::$globalStack++; + $this->instanceStack++; + + return $this->callCallableDefaultStack(); + }, + fn () => [ + 'instance' => $this->instanceStack, + 'default' => $this->defaultStack++, + ], + ); + } + + public function callCallableDefaultStackRepeatedly(): array + { + return $this->withoutRecursion( + function () { + static::$globalStack++; + $this->instanceStack++; + + return [ + $this->callCallableDefaultStackRepeatedly(), + $this->callCallableDefaultStackRepeatedly(), + $this->callCallableDefaultStackRepeatedly(), + ]; + }, + fn () => [ + 'instance' => $this->instanceStack, + 'default' => $this->defaultStack++, + ], + ); + } + + public function callOtherStack(): int + { + return $this->withoutRecursion( + function () { + $this->other->callStack(); + + return $this->other->callOtherStack(); + }, + $this->instanceStack, + ); + } +} + +class TestModel extends Model +{ +} diff --git a/tests/Database/Laravel/DatabaseConnectionFactoryTest.php b/tests/Database/Laravel/DatabaseConnectionFactoryTest.php new file mode 100755 index 000000000..4d3f6bc40 --- /dev/null +++ b/tests/Database/Laravel/DatabaseConnectionFactoryTest.php @@ -0,0 +1,181 @@ +db = new DB; + + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $this->db->addConnection([ + 'url' => 'sqlite:///:memory:', + ], 'url'); + + $this->db->addConnection([ + 'driver' => 'sqlite', + 'read' => [ + 'database' => ':memory:', + ], + 'write' => [ + 'database' => ':memory:', + ], + ], 'read_write'); + + $this->db->setAsGlobal(); + } + + public function testConnectionCanBeCreated() + { + $this->assertInstanceOf(PDO::class, $this->db->getConnection()->getPdo()); + $this->assertInstanceOf(PDO::class, $this->db->getConnection()->getReadPdo()); + $this->assertInstanceOf(PDO::class, $this->db->getConnection('read_write')->getPdo()); + $this->assertInstanceOf(PDO::class, $this->db->getConnection('read_write')->getReadPdo()); + $this->assertInstanceOf(PDO::class, $this->db->getConnection('url')->getPdo()); + $this->assertInstanceOf(PDO::class, $this->db->getConnection('url')->getReadPdo()); + } + + public function testConnectionFromUrlHasProperConfig() + { + $this->db->addConnection([ + 'url' => 'mysql://root:pass@db/local?strict=true', + 'unix_socket' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => false, + 'engine' => null, + ], 'url-config'); + + $this->assertEquals([ + 'name' => 'url-config', + 'driver' => 'mysql', + 'database' => 'local', + 'host' => 'db', + 'username' => 'root', + 'password' => 'pass', + 'unix_socket' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + ], $this->db->getConnection('url-config')->getConfig()); + } + + public function testSingleConnectionNotCreatedUntilNeeded() + { + $connection = $this->db->getConnection(); + $pdo = new ReflectionProperty(get_class($connection), 'pdo'); + $readPdo = new ReflectionProperty(get_class($connection), 'readPdo'); + + $this->assertNotInstanceOf(PDO::class, $pdo->getValue($connection)); + $this->assertNotInstanceOf(PDO::class, $readPdo->getValue($connection)); + } + + public function testReadWriteConnectionsNotCreatedUntilNeeded() + { + $connection = $this->db->getConnection('read_write'); + $pdo = new ReflectionProperty(get_class($connection), 'pdo'); + $readPdo = new ReflectionProperty(get_class($connection), 'readPdo'); + + $this->assertNotInstanceOf(PDO::class, $pdo->getValue($connection)); + $this->assertNotInstanceOf(PDO::class, $readPdo->getValue($connection)); + } + + public function testReadWriteConnectionSetsReadPdoConfig() + { + $connection = $this->db->getConnection('read_write'); + + $readPdoConfig = new ReflectionProperty(get_class($connection), 'readPdoConfig'); + + $config = $readPdoConfig->getValue($connection); + + $this->assertNotEmpty($config); + $this->assertArrayHasKey('database', $config); + $this->assertSame(':memory:', $config['database']); + } + + public function testIfDriverIsntSetExceptionIsThrown() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A driver must be specified.'); + + $factory = new ConnectionFactory($container = m::mock(Container::class)); + $factory->createConnector(['foo']); + } + + public function testExceptionIsThrownOnUnsupportedDriver() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported driver [foo]'); + + $factory = new ConnectionFactory($container = m::mock(Container::class)); + $container->shouldReceive('bound')->once()->andReturn(false); + $factory->createConnector(['driver' => 'foo']); + } + + public function testCustomConnectorsCanBeResolvedViaContainer() + { + $connector = m::mock(\Hypervel\Database\Connectors\ConnectorInterface::class); + $factory = new ConnectionFactory($container = m::mock(Container::class)); + $container->shouldReceive('bound')->once()->with('db.connector.foo')->andReturn(true); + $container->shouldReceive('get')->once()->with('db.connector.foo')->andReturn($connector); + + $this->assertSame($connector, $factory->createConnector(['driver' => 'foo'])); + } + + public function testSqliteForeignKeyConstraints() + { + $this->db->addConnection([ + 'url' => 'sqlite:///:memory:?foreign_key_constraints=true', + ], 'constraints_set'); + + $this->assertEquals(0, $this->db->getConnection()->select('PRAGMA foreign_keys')[0]->foreign_keys); + + $this->assertEquals(1, $this->db->getConnection('constraints_set')->select('PRAGMA foreign_keys')[0]->foreign_keys); + } + + public function testSqliteBusyTimeout() + { + $this->db->addConnection([ + 'url' => 'sqlite:///:memory:?busy_timeout=1234', + ], 'busy_timeout_set'); + + // Can't compare to 0, default value may be something else + $this->assertNotSame(1234, $this->db->getConnection()->select('PRAGMA busy_timeout')[0]->timeout); + + $this->assertSame(1234, $this->db->getConnection('busy_timeout_set')->select('PRAGMA busy_timeout')[0]->timeout); + } + + public function testSqliteSynchronous() + { + $this->db->addConnection([ + 'url' => 'sqlite:///:memory:?synchronous=NORMAL', + ], 'synchronous_set'); + + $this->assertSame(2, $this->db->getConnection()->select('PRAGMA synchronous')[0]->synchronous); + + $this->assertSame(1, $this->db->getConnection('synchronous_set')->select('PRAGMA synchronous')[0]->synchronous); + } +} diff --git a/tests/Database/Laravel/DatabaseConnectionTest.php b/tests/Database/Laravel/DatabaseConnectionTest.php new file mode 100755 index 000000000..e38cbce4a --- /dev/null +++ b/tests/Database/Laravel/DatabaseConnectionTest.php @@ -0,0 +1,778 @@ +getMockConnection(); + $mock = m::mock(Grammar::class); + $connection->expects($this->once())->method('getDefaultQueryGrammar')->willReturn($mock); + $connection->useDefaultQueryGrammar(); + $this->assertEquals($mock, $connection->getQueryGrammar()); + } + + public function testSettingDefaultCallsGetDefaultPostProcessor() + { + $connection = $this->getMockConnection(); + $mock = m::mock(Processor::class); + $connection->expects($this->once())->method('getDefaultPostProcessor')->willReturn($mock); + $connection->useDefaultPostProcessor(); + $this->assertEquals($mock, $connection->getPostProcessor()); + } + + public function testSelectOneCallsSelectAndReturnsSingleResult() + { + $connection = $this->getMockConnection(['select']); + $connection->expects($this->once())->method('select')->with('foo', ['bar' => 'baz'])->willReturn(['foo']); + $this->assertSame('foo', $connection->selectOne('foo', ['bar' => 'baz'])); + } + + public function testScalarCallsSelectOneAndReturnsSingleResult() + { + $connection = $this->getMockConnection(['selectOne']); + $connection->expects($this->once())->method('selectOne')->with('select count(*) from tbl')->willReturn((object) ['count(*)' => 5]); + $this->assertSame(5, $connection->scalar('select count(*) from tbl')); + } + + public function testScalarThrowsExceptionIfMultipleColumnsAreSelected() + { + $connection = $this->getMockConnection(['selectOne']); + $connection->expects($this->once())->method('selectOne')->with('select a, b from tbl')->willReturn((object) ['a' => 'a', 'b' => 'b']); + $this->expectException(MultipleColumnsSelectedException::class); + $connection->scalar('select a, b from tbl'); + } + + public function testScalarReturnsNullIfUnderlyingSelectReturnsNoRows() + { + $connection = $this->getMockConnection(['selectOne']); + $connection->expects($this->once())->method('selectOne')->with('select foo from tbl where 0=1')->willReturn(null); + $this->assertNull($connection->scalar('select foo from tbl where 0=1')); + } + + public function testSelectProperlyCallsPDO() + { + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); + $writePdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); + $writePdo->expects($this->never())->method('prepare'); + $statement = $this->getMockBuilder('PDOStatement') + ->onlyMethods(['setFetchMode', 'execute', 'fetchAll', 'bindValue']) + ->getMock(); + $statement->expects($this->once())->method('setFetchMode'); + $statement->expects($this->once())->method('bindValue')->with('foo', 'bar', 2); + $statement->expects($this->once())->method('execute'); + $statement->expects($this->once())->method('fetchAll')->willReturn(['boom']); + $pdo->expects($this->once())->method('prepare')->with('foo')->willReturn($statement); + $mock = $this->getMockConnection(['prepareBindings'], $writePdo); + $mock->setReadPdo($pdo); + $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo' => 'bar']))->willReturn(['foo' => 'bar']); + $results = $mock->select('foo', ['foo' => 'bar']); + $this->assertEquals(['boom'], $results); + $log = $mock->getQueryLog(); + $this->assertSame('foo', $log[0]['query']); + $this->assertEquals(['foo' => 'bar'], $log[0]['bindings']); + $this->assertIsNumeric($log[0]['time']); + } + + public function testSelectResultsetsReturnsMultipleRowset() + { + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); + $writePdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); + $writePdo->expects($this->never())->method('prepare'); + $statement = $this->getMockBuilder('PDOStatement') + ->onlyMethods(['setFetchMode', 'execute', 'fetchAll', 'bindValue', 'nextRowset']) + ->getMock(); + $statement->expects($this->once())->method('setFetchMode'); + $statement->expects($this->once())->method('bindValue')->with(1, 'foo', 2); + $statement->expects($this->once())->method('execute'); + $statement->expects($this->atLeastOnce())->method('fetchAll')->willReturn(['boom']); + $statement->expects($this->atLeastOnce())->method('nextRowset')->willReturnCallback(function () { + static $i = 1; + + return ++$i <= 2; + }); + $pdo->expects($this->once())->method('prepare')->with('CALL a_procedure(?)')->willReturn($statement); + $mock = $this->getMockConnection(['prepareBindings'], $writePdo); + $mock->setReadPdo($pdo); + $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo']))->willReturn(['foo']); + $results = $mock->selectResultsets('CALL a_procedure(?)', ['foo']); + $this->assertEquals([['boom'], ['boom']], $results); + $log = $mock->getQueryLog(); + $this->assertSame('CALL a_procedure(?)', $log[0]['query']); + $this->assertEquals(['foo'], $log[0]['bindings']); + $this->assertIsNumeric($log[0]['time']); + } + + public function testInsertCallsTheStatementMethod() + { + $connection = $this->getMockConnection(['statement']); + $connection->expects($this->once())->method('statement')->with($this->equalTo('foo'), $this->equalTo(['bar']))->willReturn(true); + $results = $connection->insert('foo', ['bar']); + $this->assertTrue($results); + } + + public function testUpdateCallsTheAffectingStatementMethod() + { + $connection = $this->getMockConnection(['affectingStatement']); + $connection->expects($this->once())->method('affectingStatement')->with($this->equalTo('foo'), $this->equalTo(['bar']))->willReturn(42); + $results = $connection->update('foo', ['bar']); + $this->assertSame(42, $results); + } + + public function testDeleteCallsTheAffectingStatementMethod() + { + $connection = $this->getMockConnection(['affectingStatement']); + $connection->expects($this->once())->method('affectingStatement')->with($this->equalTo('foo'), $this->equalTo(['bar']))->willReturn(1); + $results = $connection->delete('foo', ['bar']); + $this->assertSame(1, $results); + } + + public function testStatementProperlyCallsPDO() + { + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); + $statement = $this->getMockBuilder('PDOStatement')->onlyMethods(['execute', 'bindValue'])->getMock(); + $statement->expects($this->once())->method('bindValue')->with(1, 'bar', 2); + $statement->expects($this->once())->method('execute')->willReturn(true); + $pdo->expects($this->once())->method('prepare')->with($this->equalTo('foo'))->willReturn($statement); + $mock = $this->getMockConnection(['prepareBindings'], $pdo); + $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['bar']))->willReturn(['bar']); + $results = $mock->statement('foo', ['bar']); + $this->assertTrue($results); + $log = $mock->getQueryLog(); + $this->assertSame('foo', $log[0]['query']); + $this->assertEquals(['bar'], $log[0]['bindings']); + $this->assertIsNumeric($log[0]['time']); + } + + public function testAffectingStatementProperlyCallsPDO() + { + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['prepare'])->getMock(); + $statement = $this->getMockBuilder('PDOStatement')->onlyMethods(['execute', 'rowCount', 'bindValue'])->getMock(); + $statement->expects($this->once())->method('bindValue')->with('foo', 'bar', 2); + $statement->expects($this->once())->method('execute'); + $statement->expects($this->once())->method('rowCount')->willReturn(42); + $pdo->expects($this->once())->method('prepare')->with('foo')->willReturn($statement); + $mock = $this->getMockConnection(['prepareBindings'], $pdo); + $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo' => 'bar']))->willReturn(['foo' => 'bar']); + $results = $mock->update('foo', ['foo' => 'bar']); + $this->assertSame(42, $results); + $log = $mock->getQueryLog(); + $this->assertSame('foo', $log[0]['query']); + $this->assertEquals(['foo' => 'bar'], $log[0]['bindings']); + $this->assertIsNumeric($log[0]['time']); + } + + public function testTransactionLevelNotIncrementedOnTransactionException() + { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $pdo->expects($this->once())->method('beginTransaction')->will($this->throwException(new Exception)); + $connection = $this->getMockConnection([], $pdo); + try { + $connection->beginTransaction(); + } catch (Exception) { + $this->assertEquals(0, $connection->transactionLevel()); + } + } + + public function testBeginTransactionMethodRetriesOnFailure() + { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $pdo->method('beginTransaction') + ->willReturnOnConsecutiveCalls($this->throwException(new ErrorException('server has gone away')), true); + $connection = $this->getMockConnection(['reconnect'], $pdo); + $connection->expects($this->once())->method('reconnect'); + $connection->beginTransaction(); + $this->assertEquals(1, $connection->transactionLevel()); + } + + public function testBeginTransactionMethodReconnectsMissingConnection() + { + $connection = $this->getMockConnection(); + $connection->setReconnector(function ($connection) { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $connection->setPdo($pdo); + }); + $connection->disconnect(); + $connection->beginTransaction(); + $this->assertEquals(1, $connection->transactionLevel()); + } + + public function testBeginTransactionMethodNeverRetriesIfWithinTransaction() + { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('exec')->will($this->throwException(new Exception)); + $connection = $this->getMockConnection(['reconnect'], $pdo); + $queryGrammar = $this->createMock(Grammar::class); + $queryGrammar->expects($this->once())->method('compileSavepoint')->willReturn('trans1'); + $queryGrammar->expects($this->once())->method('supportsSavepoints')->willReturn(true); + $connection->setQueryGrammar($queryGrammar); + $connection->expects($this->never())->method('reconnect'); + $connection->beginTransaction(); + $this->assertEquals(1, $connection->transactionLevel()); + try { + $connection->beginTransaction(); + } catch (Exception) { + $this->assertEquals(1, $connection->transactionLevel()); + } + } + + public function testSwapPDOWithOpenTransactionResetsTransactionLevel() + { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $pdo->expects($this->once())->method('beginTransaction')->willReturn(true); + $connection = $this->getMockConnection([], $pdo); + $connection->beginTransaction(); + $connection->disconnect(); + $this->assertEquals(0, $connection->transactionLevel()); + } + + public function testBeganTransactionFiresEventsIfSet() + { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $connection = $this->getMockConnection(['getName'], $pdo); + $connection->expects($this->any())->method('getName')->willReturn('name'); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(TransactionBeginning::class)); + $connection->beginTransaction(); + } + + public function testCommittedFiresEventsIfSet() + { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $connection = $this->getMockConnection(['getName'], $pdo); + $connection->expects($this->any())->method('getName')->willReturn('name'); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(TransactionCommitted::class)); + $connection->commit(); + } + + public function testCommittingFiresEventsIfSet() + { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $connection = $this->getMockConnection(['getName', 'transactionLevel'], $pdo); + $connection->expects($this->any())->method('getName')->willReturn('name'); + $connection->expects($this->any())->method('transactionLevel')->willReturn(1); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(TransactionCommitting::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(TransactionCommitted::class)); + $connection->commit(); + } + + public function testRollBackedFiresEventsIfSet() + { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $connection = $this->getMockConnection(['getName'], $pdo); + $connection->expects($this->any())->method('getName')->willReturn('name'); + $connection->beginTransaction(); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(TransactionRolledBack::class)); + $connection->rollBack(); + } + + public function testRedundantRollBackFiresNoEvent() + { + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $connection = $this->getMockConnection(['getName'], $pdo); + $connection->expects($this->any())->method('getName')->willReturn('name'); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldNotReceive('dispatch'); + $connection->rollBack(); + } + + public function testTransactionMethodRunsSuccessfully() + { + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['beginTransaction', 'commit'])->getMock(); + $mock = $this->getMockConnection([], $pdo); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('commit'); + $result = $mock->transaction(function ($db) { + return $db; + }); + $this->assertEquals($mock, $result); + } + + public function testTransactionRetriesOnSerializationFailure() + { + $this->expectException(PDOException::class); + $this->expectExceptionMessage('Serialization failure'); + + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['beginTransaction', 'commit', 'rollBack'])->getMock(); + $mock = $this->getMockConnection([], $pdo); + $pdo->expects($this->exactly(3))->method('commit')->will($this->throwException(new DatabaseConnectionTestMockPDOException('Serialization failure', '40001'))); + $pdo->expects($this->exactly(3))->method('beginTransaction'); + $pdo->expects($this->never())->method('rollBack'); + $mock->transaction(function () { + }, 3); + } + + public function testTransactionMethodRetriesOnDeadlock() + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Deadlock found when trying to get lock (Connection: conn, SQL: )'); + + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['inTransaction', 'beginTransaction', 'commit', 'rollBack'])->getMock(); + $mock = $this->getMockConnection([], $pdo); + $pdo->method('inTransaction')->willReturn(true); + $pdo->expects($this->exactly(3))->method('beginTransaction'); + $pdo->expects($this->exactly(3))->method('rollBack'); + $pdo->expects($this->never())->method('commit'); + $mock->transaction(function () { + throw new QueryException('conn', '', [], new Exception('Deadlock found when trying to get lock')); + }, 3); + } + + public function testTransactionMethodRollsbackAndThrows() + { + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['inTransaction', 'beginTransaction', 'commit', 'rollBack'])->getMock(); + $mock = $this->getMockConnection([], $pdo); + // $pdo->expects($this->once())->method('inTransaction'); + $pdo->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('rollBack'); + $pdo->expects($this->never())->method('commit'); + try { + $mock->transaction(function () { + throw new Exception('foo'); + }); + } catch (Exception $e) { + $this->assertSame('foo', $e->getMessage()); + } + } + + public function testOnLostConnectionPDOIsNotSwappedWithinATransaction() + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('server has gone away (Connection: test, Host: , Port: , Database: , SQL: foo)'); + + $pdo = m::mock(PDO::class); + $pdo->shouldReceive('beginTransaction')->once(); + $statement = m::mock(PDOStatement::class); + $pdo->shouldReceive('prepare')->once()->andReturn($statement); + $statement->shouldReceive('execute')->once()->andThrow(new PDOException('server has gone away')); + + $connection = new Connection($pdo, '', '', ['name' => 'test', 'driver' => 'mysql']); + $connection->beginTransaction(); + $connection->statement('foo'); + } + + public function testOnLostConnectionPDOIsSwappedOutsideTransaction() + { + $pdo = m::mock(PDO::class); + + $statement = m::mock(PDOStatement::class); + $statement->shouldReceive('execute')->once()->andThrow(new PDOException('server has gone away')); + $statement->shouldReceive('execute')->once()->andReturn(true); + + $pdo->shouldReceive('prepare')->twice()->andReturn($statement); + + $connection = new Connection($pdo, '', '', ['name' => 'test', 'driver' => 'mysql']); + + $called = false; + + $connection->setReconnector(function ($connection) use (&$called) { + $called = true; + }); + + $this->assertTrue($connection->statement('foo')); + + $this->assertTrue($called); + } + + public function testRunMethodRetriesOnFailure() + { + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + + $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); + $mock = $this->getMockConnection(['tryAgainIfCausedByLostConnection'], $pdo); + $mock->expects($this->once())->method('tryAgainIfCausedByLostConnection'); + + $method->invokeArgs($mock, ['', [], function () { + throw new QueryException('', '', [], new Exception); + }]); + } + + public function testRunMethodNeverRetriesIfWithinTransaction() + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('(Connection: conn, SQL: ) (Connection: test, Host: , Port: , Database: , SQL: )'); + + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['beginTransaction'])->getMock(); + $mock = $this->getMockConnection(['tryAgainIfCausedByLostConnection'], $pdo); + $pdo->expects($this->once())->method('beginTransaction'); + $mock->expects($this->never())->method('tryAgainIfCausedByLostConnection'); + $mock->beginTransaction(); + + $method->invokeArgs($mock, ['', [], function () { + throw new QueryException('conn', '', [], new Exception); + }]); + } + + public function testFromCreatesNewQueryBuilder() + { + $conn = $this->getMockConnection(); + $conn->setQueryGrammar(m::mock(Grammar::class)); + $conn->setPostProcessor(m::mock(Processor::class)); + $builder = $conn->table('users'); + $this->assertInstanceOf(BaseBuilder::class, $builder); + $this->assertSame('users', $builder->from); + } + + public function testPrepareBindings() + { + $date = m::mock(DateTime::class); + $date->shouldReceive('format')->once()->with('foo')->andReturn('bar'); + $bindings = ['test' => $date]; + $conn = $this->getMockConnection(); + $grammar = m::mock(Grammar::class); + $grammar->shouldReceive('getDateFormat')->once()->andReturn('foo'); + $conn->setQueryGrammar($grammar); + $result = $conn->prepareBindings($bindings); + $this->assertEquals(['test' => 'bar'], $result); + } + + public function testLogQueryFiresEventsIfSet() + { + $connection = $this->getMockConnection(); + $connection->logQuery('foo', [], time()); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(QueryExecuted::class)); + $connection->logQuery('foo', [], null); + } + + public function testBeforeExecutingHooksCanBeRegistered() + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('The callback was fired'); + + $connection = $this->getMockConnection(); + $connection->beforeExecuting(function () { + throw new Exception('The callback was fired'); + }); + $connection->select('foo bar', ['baz']); + } + + public function testBeforeStartingTransactionHooksCanBeRegistered() + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('The callback was fired'); + + $connection = $this->getMockConnection(); + $connection->beforeStartingTransaction(function () { + throw new Exception('The callback was fired'); + }); + $connection->beginTransaction(); + } + + public function testPretendOnlyLogsQueries() + { + $connection = $this->getMockConnection(); + $grammar = m::mock(Grammar::class); + $grammar->shouldReceive('substituteBindingsIntoRawSql')->andReturnUsing(fn ($query) => $query); + $connection->setQueryGrammar($grammar); + $queries = $connection->pretend(function ($connection) { + $connection->select('foo bar', ['baz']); + }); + $this->assertSame('foo bar', $queries[0]['query']); + $this->assertEquals(['baz'], $queries[0]['bindings']); + } + + public function testSchemaBuilderCanBeCreated() + { + $connection = $this->getMockConnection(); + $schema = $connection->getSchemaBuilder(); + $this->assertInstanceOf(Builder::class, $schema); + $this->assertSame($connection, $schema->getConnection()); + } + + public function testGetRawQueryLog() + { + $mock = $this->getMockConnection(['getQueryLog']); + $mock->expects($this->once())->method('getQueryLog')->willReturn([ + [ + 'query' => 'select * from tbl where col = ?', + 'bindings' => [ + 0 => 'foo', + ], + 'time' => 1.23, + ], + ]); + + $queryGrammar = $this->createMock(Grammar::class); + $queryGrammar->expects($this->once()) + ->method('substituteBindingsIntoRawSql') + ->with('select * from tbl where col = ?', ['foo']) + ->willReturn("select * from tbl where col = 'foo'"); + $mock->setQueryGrammar($queryGrammar); + + $log = $mock->getRawQueryLog(); + + $this->assertEquals("select * from tbl where col = 'foo'", $log[0]['raw_query']); + $this->assertEquals(1.23, $log[0]['time']); + } + + public function testQueryExceptionContainsReadConnectionDetailsWhenUsingReadPdo() + { + // Create write PDO mock that will NOT be used for this query + $writePdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class) + ->onlyMethods(['prepare']) + ->getMock(); + $writePdo->expects($this->never())->method('prepare'); + + // Create read PDO mock that throws an exception + $readPdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class) + ->onlyMethods(['prepare']) + ->getMock(); + $readPdo->expects($this->once()) + ->method('prepare') + ->willThrowException(new PDOException('Connection refused')); + + // Write configuration (passed to constructor) + $writeConfig = [ + 'driver' => 'mysql', + 'name' => 'mysql', + 'host' => '192.168.1.10', + 'port' => '3306', + 'database' => 'write_db', + ]; + + // Create connection with write config + $connection = new Connection($writePdo, 'write_db', '', $writeConfig); + $connection->useDefaultQueryGrammar(); + $connection->useDefaultPostProcessor(); + + // Read configuration (different from write) + $readConfig = [ + 'host' => '192.168.1.20', + 'port' => '3307', + 'database' => 'read_db', + ]; + + // Set read PDO and its config + $connection->setReadPdo($readPdo); + $connection->setReadPdoConfig($readConfig); + + try { + $connection->select('SELECT * FROM users', useReadPdo: true); + $this->fail('Expected QueryException was not thrown'); + } catch (QueryException $e) { + // Verify the readWriteType is correctly set to 'read' + $this->assertSame('read', $e->readWriteType); + + // Verify connection details show READ config, not write config + $connectionDetails = $e->getConnectionDetails(); + $this->assertSame('192.168.1.20', $connectionDetails['host']); + $this->assertSame('3307', $connectionDetails['port']); + $this->assertSame('read_db', $connectionDetails['database']); + } + } + + public function testQueryExceptionContainsReadConnectionDetailsWhenReadPdoConnectionFails() + { + // Write PDO (won't be used) + $writePdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class) + ->onlyMethods(['prepare']) + ->getMock(); + $writePdo->expects($this->never())->method('prepare'); + + // Write configuration + $writeConfig = [ + 'driver' => 'mysql', + 'name' => 'mysql', + 'host' => '192.168.1.10', + 'port' => '3306', + 'database' => 'write_db', + ]; + + $connection = new Connection($writePdo, 'write_db', '', $writeConfig); + $connection->useDefaultQueryGrammar(); + $connection->useDefaultPostProcessor(); + + // Read config (different host) + $readConfig = [ + 'host' => '192.168.1.20', + 'port' => '3307', + 'database' => 'read_db', + ]; + + // Simulate lazy PDO that fails during connection (e.g., SET NAMES fails) + $connection->setReadPdo(function () { + throw new PDOException('SQLSTATE[HY000] SET NAMES failed'); + }); + $connection->setReadPdoConfig($readConfig); + + try { + $connection->select('SELECT * FROM users', useReadPdo: true); + $this->fail('Expected QueryException was not thrown'); + } catch (QueryException $e) { + $this->assertSame('read', $e->readWriteType); + + // Verify connection details show READ config even for connection-time failures + $connectionDetails = $e->getConnectionDetails(); + $this->assertSame('192.168.1.20', $connectionDetails['host']); + $this->assertSame('3307', $connectionDetails['port']); + $this->assertSame('read_db', $connectionDetails['database']); + } + } + + public function testQueryExceptionContainsWriteConnectionDetailsWhenUsingWritePdo() + { + // Create write PDO mock that throws an exception + $writePdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class) + ->onlyMethods(['prepare']) + ->getMock(); + $writePdo->expects($this->once()) + ->method('prepare') + ->willThrowException(new PDOException('Connection refused')); + + // Create read PDO mock that will NOT be used + $readPdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class) + ->onlyMethods(['prepare']) + ->getMock(); + $readPdo->expects($this->never())->method('prepare'); + + // Write configuration (passed to constructor) + $writeConfig = [ + 'driver' => 'mysql', + 'name' => 'mysql', + 'host' => '192.168.1.10', + 'port' => '3306', + 'database' => 'write_db', + ]; + + $connection = new Connection($writePdo, 'write_db', '', $writeConfig); + $connection->useDefaultQueryGrammar(); + $connection->useDefaultPostProcessor(); + + // Read configuration (different from write) + $readConfig = [ + 'host' => '192.168.1.20', + 'port' => '3307', + 'database' => 'read_db', + ]; + + $connection->setReadPdo($readPdo); + $connection->setReadPdoConfig($readConfig); + + try { + $connection->select('SELECT * FROM users', useReadPdo: false); + $this->fail('Expected QueryException was not thrown'); + } catch (QueryException $e) { + // Verify the readWriteType is correctly set to 'write' + $this->assertSame('write', $e->readWriteType); + + // Verify connection details show WRITE config, not read config + $connectionDetails = $e->getConnectionDetails(); + $this->assertSame('192.168.1.10', $connectionDetails['host']); + $this->assertSame('3306', $connectionDetails['port']); + $this->assertSame('write_db', $connectionDetails['database']); + } + } + + public function testQueryExceptionContainsWriteConnectionDetailsWhenWritePdoConnectionFails() + { + // Write configuration + $writeConfig = [ + 'driver' => 'mysql', + 'name' => 'mysql', + 'host' => '192.168.1.10', + 'port' => '3306', + 'database' => 'write_db', + ]; + + // Simulate lazy write PDO that fails during connection (e.g., SET NAMES fails) + $connection = new Connection(function () { + throw new PDOException('SQLSTATE[HY000] SET NAMES failed'); + }, 'write_db', '', $writeConfig); + $connection->useDefaultQueryGrammar(); + $connection->useDefaultPostProcessor(); + + // Read config (different host) + $readConfig = [ + 'host' => '192.168.1.20', + 'port' => '3307', + 'database' => 'read_db', + ]; + + $connection->setReadPdo(new DatabaseConnectionTestMockPDO); + $connection->setReadPdoConfig($readConfig); + + try { + $connection->select('SELECT * FROM users', useReadPdo: false); + $this->fail('Expected QueryException was not thrown'); + } catch (QueryException $e) { + $this->assertSame('write', $e->readWriteType); + + // Verify connection details show WRITE config even for connection-time failures + $connectionDetails = $e->getConnectionDetails(); + $this->assertSame('192.168.1.10', $connectionDetails['host']); + $this->assertSame('3306', $connectionDetails['port']); + $this->assertSame('write_db', $connectionDetails['database']); + } + } + + protected function getMockConnection($methods = [], $pdo = null) + { + $pdo = $pdo ?: new DatabaseConnectionTestMockPDO; + $defaults = ['getDefaultQueryGrammar', 'getDefaultPostProcessor', 'getDefaultSchemaGrammar']; + $connection = $this->getMockBuilder(Connection::class)->onlyMethods(array_merge($defaults, $methods))->setConstructorArgs([$pdo, 'test_db', '', ['name' => 'test', 'driver' => 'mysql']])->getMock(); + $connection->method('getDefaultSchemaGrammar')->willReturn(m::mock(SchemaGrammar::class)); + $connection->enableQueryLog(); + + return $connection; + } +} + +class DatabaseConnectionTestMockPDO extends PDO +{ + public function __construct() + { + // + } +} + +class DatabaseConnectionTestMockPDOException extends PDOException +{ + /** + * Overrides Exception::__construct, which casts $code to integer, so that we can create + * an exception with a string $code consistent with the real PDOException behavior. + * + * @param string|null $message + * @param string|null $code + */ + public function __construct($message = null, $code = null) + { + $this->message = $message; + $this->code = $code; + } +} diff --git a/tests/Database/Laravel/DatabaseConnectorTest.php b/tests/Database/Laravel/DatabaseConnectorTest.php new file mode 100755 index 000000000..9b6d7aa0f --- /dev/null +++ b/tests/Database/Laravel/DatabaseConnectorTest.php @@ -0,0 +1,293 @@ +setDefaultOptions([0 => 'foo', 1 => 'bar']); + $this->assertEquals([0 => 'baz', 1 => 'bar', 2 => 'boom'], $connector->getOptions(['options' => [0 => 'baz', 2 => 'boom']])); + } + + #[DataProvider('mySqlConnectProvider')] + public function testMySqlConnectCallsCreateConnectionWithProperArguments($dsn, $config) + { + $connector = $this->getMockBuilder(MySqlConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $connection->shouldReceive('exec')->once()->with('use `bar`;')->andReturn(true); + $connection->shouldReceive('exec')->once()->with("SET NAMES 'utf8' COLLATE 'utf8_unicode_ci';")->andReturn(true); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public static function mySqlConnectProvider() + { + return [ + ['mysql:host=foo;dbname=bar', ['host' => 'foo', 'database' => 'bar', 'collation' => 'utf8_unicode_ci', 'charset' => 'utf8']], + ['mysql:host=foo;port=111;dbname=bar', ['host' => 'foo', 'database' => 'bar', 'port' => 111, 'collation' => 'utf8_unicode_ci', 'charset' => 'utf8']], + ['mysql:unix_socket=baz;dbname=bar', ['host' => 'foo', 'database' => 'bar', 'port' => 111, 'unix_socket' => 'baz', 'collation' => 'utf8_unicode_ci', 'charset' => 'utf8']], + ]; + } + + public function testMySqlConnectCallsCreateConnectionWithIsolationLevel() + { + $dsn = 'mysql:host=foo;dbname=bar'; + $config = ['host' => 'foo', 'database' => 'bar', 'collation' => 'utf8_unicode_ci', 'charset' => 'utf8', 'isolation_level' => 'REPEATABLE READ']; + + $connector = $this->getMockBuilder(MySqlConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $connection->shouldReceive('exec')->once()->with('use `bar`;')->andReturn(true); + $connection->shouldReceive('exec')->once()->with('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;')->andReturn(true); + $connection->shouldReceive('exec')->once()->with("SET NAMES 'utf8' COLLATE 'utf8_unicode_ci';")->andReturn(true); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public function testPostgresConnectCallsCreateConnectionWithProperArguments() + { + $dsn = 'pgsql:host=foo;dbname=\'bar\';port=111;client_encoding=\'utf8\''; + $config = ['host' => 'foo', 'database' => 'bar', 'port' => 111, 'charset' => 'utf8']; + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->zeroOrMoreTimes()->andReturn($statement); + $statement->shouldReceive('execute')->zeroOrMoreTimes(); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + /** + * @param string $searchPath + * @param string $expectedSql + */ + #[DataProvider('provideSearchPaths')] + public function testPostgresSearchPathIsSet($searchPath, $expectedSql) + { + $dsn = 'pgsql:host=foo;dbname=\'bar\';client_encoding=\'utf8\''; + $config = ['host' => 'foo', 'database' => 'bar', 'search_path' => $searchPath, 'charset' => 'utf8']; + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with($expectedSql)->andReturn($statement); + $statement->shouldReceive('execute')->once(); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public static function provideSearchPaths() + { + return [ + 'all-lowercase' => [ + 'public', + 'set search_path to "public"', + ], + 'case-sensitive' => [ + 'Public', + 'set search_path to "Public"', + ], + 'special characters' => [ + '¡foo_bar-Baz!.Áüõß', + 'set search_path to "¡foo_bar-Baz!.Áüõß"', + ], + 'single-quoted' => [ + "'public'", + 'set search_path to "public"', + ], + 'double-quoted' => [ + '"public"', + 'set search_path to "public"', + ], + 'variable' => [ + '$user', + 'set search_path to "$user"', + ], + 'delimit space' => [ + 'public user', + 'set search_path to "public", "user"', + ], + 'delimit newline' => [ + "public\nuser\r\n\ttest", + 'set search_path to "public", "user", "test"', + ], + 'delimit comma' => [ + 'public,user', + 'set search_path to "public", "user"', + ], + 'delimit comma and space' => [ + 'public, user', + 'set search_path to "public", "user"', + ], + 'single-quoted many' => [ + "'public', 'user'", + 'set search_path to "public", "user"', + ], + 'double-quoted many' => [ + '"public", "user"', + 'set search_path to "public", "user"', + ], + 'quoted space is unsupported in string' => [ + '"public user"', + 'set search_path to "public", "user"', + ], + 'array' => [ + ['public', 'user'], + 'set search_path to "public", "user"', + ], + 'array with variable' => [ + ['public', '$user'], + 'set search_path to "public", "$user"', + ], + 'array with delimiter characters' => [ + ['public', '"user"', "'test'", 'spaced schema'], + 'set search_path to "public", "user", "test", "spaced schema"', + ], + ]; + } + + public function testPostgresSearchPathFallbackToConfigKeySchema() + { + $dsn = 'pgsql:host=foo;dbname=\'bar\';client_encoding=\'utf8\''; + $config = ['host' => 'foo', 'database' => 'bar', 'schema' => ['public', '"user"'], 'charset' => 'utf8']; + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with('set search_path to "public", "user"')->andReturn($statement); + $statement->shouldReceive('execute')->once(); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public function testPostgresApplicationNameIsSet() + { + $dsn = 'pgsql:host=foo;dbname=\'bar\';client_encoding=\'utf8\';application_name=\'Laravel App\''; + $config = ['host' => 'foo', 'database' => 'bar', 'charset' => 'utf8', 'application_name' => 'Laravel App']; + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->zeroOrMoreTimes()->andReturn($statement); + $statement->shouldReceive('execute')->zeroOrMoreTimes(); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public function testPostgresApplicationUseAlternativeDatabaseName() + { + $dsn = 'pgsql:dbname=\'baz\''; + $config = ['database' => 'bar', 'connect_via_database' => 'baz']; + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->zeroOrMoreTimes()->andReturn($statement); + $statement->shouldReceive('execute')->zeroOrMoreTimes(); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public function testPostgresApplicationUseAlternativeDatabaseNameAndPort() + { + $dsn = 'pgsql:dbname=\'baz\';port=2345'; + $config = ['database' => 'bar', 'connect_via_database' => 'baz', 'port' => 5432, 'connect_via_port' => 2345]; + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->zeroOrMoreTimes()->andReturn($statement); + $statement->shouldReceive('execute')->zeroOrMoreTimes(); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public function testPostgresConnectorReadsIsolationLevelFromConfig() + { + $dsn = 'pgsql:host=foo;dbname=\'bar\';port=111'; + $config = ['host' => 'foo', 'database' => 'bar', 'port' => 111, 'isolation_level' => 'SERIALIZABLE']; + $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $statement = m::mock(PDOStatement::class); + $connection->shouldReceive('prepare')->once()->with('set session characteristics as transaction isolation level SERIALIZABLE')->andReturn($statement); + $statement->shouldReceive('execute')->zeroOrMoreTimes(); + $connection->shouldReceive('exec')->zeroOrMoreTimes(); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public function testSQLiteMemoryDatabasesMayBeConnectedTo() + { + $dsn = 'sqlite::memory:'; + $config = ['database' => ':memory:']; + $connector = $this->getMockBuilder(SQLiteConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public function testSQLiteNamedMemoryDatabasesMayBeConnectedTo() + { + $dsn = 'sqlite:file:mydb?mode=memory&cache=shared'; + $config = ['database' => 'file:mydb?mode=memory&cache=shared']; + $connector = $this->getMockBuilder(SQLiteConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } + + public function testSQLiteFileDatabasesMayBeConnectedTo() + { + $dsn = 'sqlite:'.__DIR__; + $config = ['database' => __DIR__]; + $connector = $this->getMockBuilder(SQLiteConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + $result = $connector->connect($config); + + $this->assertSame($result, $connection); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentAsBinaryCastTest.php b/tests/Database/Laravel/DatabaseEloquentAsBinaryCastTest.php new file mode 100644 index 000000000..bdbe769eb --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentAsBinaryCastTest.php @@ -0,0 +1,127 @@ +getProperty('customCodecs'); + $property->setValue(null, []); + + parent::tearDown(); + } + + public function testCastThrowsWhenFormatMissing() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The binary codec format is required.'); + + $model = new AsBinaryTestModel; + $model->setRawAttributes(['no_format' => 'value']); + $model->no_format; + } + + public function testCastThrowsOnInvalidFormat() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported binary codec format [invalid]. Allowed formats are: uuid, ulid.'); + + $model = new AsBinaryTestModel; + $model->setRawAttributes(['invalid_format' => 'value']); + $model->invalid_format; + } + + public function testGetDecodesUuidFromBinary() + { + $uuid = '550e8400-e29b-41d4-a716-446655440000'; + $model = new AsBinaryTestModel; + $model->setRawAttributes(['uuid' => Uuid::fromString($uuid)->getBytes()]); + + $this->assertSame($uuid, $model->uuid); + } + + public function testSetEncodesUuidToBinary() + { + $uuid = '550e8400-e29b-41d4-a716-446655440000'; + $model = new AsBinaryTestModel; + $model->uuid = $uuid; + + $this->assertSame(Uuid::fromString($uuid)->getBytes(), $model->getAttributes()['uuid']); + } + + public function testGetDecodesUlidFromBinary() + { + $ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $model = new AsBinaryTestModel; + $model->setRawAttributes(['ulid' => Ulid::fromString($ulid)->toBinary()]); + + $this->assertSame($ulid, $model->ulid); + } + + public function testSetEncodesUlidToBinary() + { + $ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $model = new AsBinaryTestModel; + $model->ulid = $ulid; + + $this->assertSame(Ulid::fromString($ulid)->toBinary(), $model->getAttributes()['ulid']); + } + + public function testGetReturnsNullForNullValue() + { + $model = new AsBinaryTestModel; + $model->setRawAttributes(['uuid' => null]); + + $this->assertNull($model->uuid); + } + + public function testSetEncodesNullToNull() + { + $model = new AsBinaryTestModel; + $model->uuid = null; + + $this->assertNull($model->getAttributes()['uuid']); + } + + public function testUuidHelperMethod() + { + $this->assertSame(AsBinary::class.':uuid', AsBinary::uuid()); + } + + public function testUlidHelperMethod() + { + $this->assertSame(AsBinary::class.':ulid', AsBinary::ulid()); + } + + public function testOfHelperMethod() + { + $this->assertSame(AsBinary::class.':custom', AsBinary::of('custom')); + } +} + +class AsBinaryTestModel extends Model +{ + protected array $guarded = []; + + protected function casts(): array + { + return [ + 'uuid' => AsBinary::class.':uuid', + 'ulid' => AsBinary::class.':ulid', + 'no_format' => AsBinary::class, + 'invalid_format' => AsBinary::class.':invalid', + ]; + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyAggregateTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyAggregateTest.php new file mode 100644 index 000000000..5ef2bf339 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyAggregateTest.php @@ -0,0 +1,200 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + public function testWithSumDifferentTables() + { + $this->seedData(); + + $order = BelongsToManyAggregateTestTestOrder::query() + ->withSum('products as total_products', 'order_product.quantity') + ->first(); + + $this->assertEquals(12, $order->total_products); + } + + public function testWithSumSameTable() + { + $this->seedData(); + + $order = BelongsToManyAggregateTestTestTransaction::query() + ->withSum('allocatedTo as total_allocated', 'allocations.amount') + ->first(); + + $this->assertEquals(1200, $order->total_allocated); + } + + public function testWithSumExpression() + { + $this->seedData(); + + $order = BelongsToManyAggregateTestTestTransaction::query() + ->withSum('allocatedTo as total_allocated', new Expression('allocations.amount * 2')) + ->first(); + + $this->assertEquals(2400, $order->total_allocated); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('orders', function ($table) { + $table->increments('id'); + }); + + $this->schema()->create('products', function ($table) { + $table->increments('id'); + }); + + $this->schema()->create('order_product', function ($table) { + $table->integer('order_id')->unsigned(); + $table->foreign('order_id')->references('id')->on('orders'); + $table->integer('product_id')->unsigned(); + $table->foreign('product_id')->references('id')->on('products'); + $table->integer('quantity')->unsigned(); + }); + + $this->schema()->create('transactions', function ($table) { + $table->increments('id'); + $table->integer('value')->unsigned(); + }); + + $this->schema()->create('allocations', function ($table) { + $table->integer('from_id')->unsigned(); + $table->foreign('from_id')->references('id')->on('transactions'); + $table->integer('to_id')->unsigned(); + $table->foreign('to_id')->references('id')->on('transactions'); + $table->integer('amount')->unsigned(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('orders'); + $this->schema()->drop('products'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + $order = BelongsToManyAggregateTestTestOrder::create(['id' => 1]); + + BelongsToManyAggregateTestTestProduct::query()->insert([ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ]); + + $order->products()->sync([ + 1 => ['quantity' => 3], + 2 => ['quantity' => 4], + 3 => ['quantity' => 5], + ]); + + $transaction = BelongsToManyAggregateTestTestTransaction::create(['id' => 1, 'value' => 1200]); + + BelongsToManyAggregateTestTestTransaction::query()->insert([ + ['id' => 2, 'value' => -300], + ['id' => 3, 'value' => -400], + ['id' => 4, 'value' => -500], + ]); + + $transaction->allocatedTo()->sync([ + 2 => ['amount' => 300], + 3 => ['amount' => 400], + 4 => ['amount' => 500], + ]); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class BelongsToManyAggregateTestTestOrder extends Eloquent +{ + protected ?string $table = 'orders'; + protected array $fillable = ['id']; + public bool $timestamps = false; + + public function products() + { + return $this + ->belongsToMany(BelongsToManyAggregateTestTestProduct::class, 'order_product', 'order_id', 'product_id') + ->withPivot('quantity'); + } +} + +class BelongsToManyAggregateTestTestProduct extends Eloquent +{ + protected ?string $table = 'products'; + protected array $fillable = ['id']; + public bool $timestamps = false; +} + +class BelongsToManyAggregateTestTestTransaction extends Eloquent +{ + protected ?string $table = 'transactions'; + protected array $fillable = ['id', 'value']; + public bool $timestamps = false; + + public function allocatedTo() + { + return $this + ->belongsToMany(BelongsToManyAggregateTestTestTransaction::class, 'allocations', 'from_id', 'to_id') + ->withPivot('quantity'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyChunkByIdTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyChunkByIdTest.php new file mode 100644 index 000000000..acb07f5bb --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyChunkByIdTest.php @@ -0,0 +1,154 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + }); + + $this->schema()->create('articles', function ($table) { + $table->increments('id'); + $table->string('title'); + }); + + $this->schema()->create('article_user', function ($table) { + $table->increments('id'); + $table->integer('article_id')->unsigned(); + $table->foreign('article_id')->references('id')->on('articles'); + $table->integer('user_id')->unsigned(); + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + public function testBelongsToChunkById() + { + $this->seedData(); + + $user = BelongsToManyChunkByIdTestTestUser::query()->first(); + $i = 0; + + $user->articles()->chunkById(1, function (Collection $collection) use (&$i) { + $i++; + $this->assertEquals($i, $collection->first()->id); + }); + + $this->assertSame(3, $i); + } + + public function testBelongsToChunkByIdDesc() + { + $this->seedData(); + + $user = BelongsToManyChunkByIdTestTestUser::query()->first(); + $i = 0; + + $user->articles()->chunkByIdDesc(1, function (Collection $collection) use (&$i) { + $this->assertEquals(3 - $i, $collection->first()->id); + $i++; + }); + + $this->assertSame(3, $i); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = BelongsToManyChunkByIdTestTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + BelongsToManyChunkByIdTestTestArticle::query()->insert([ + ['id' => 1, 'title' => 'Another title'], + ['id' => 2, 'title' => 'Another title'], + ['id' => 3, 'title' => 'Another title'], + ]); + + $user->articles()->sync([3, 1, 2]); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class BelongsToManyChunkByIdTestTestUser extends Eloquent +{ + protected ?string $table = 'users'; + protected array $fillable = ['id', 'email']; + public bool $timestamps = false; + + public function articles() + { + return $this->belongsToMany(BelongsToManyChunkByIdTestTestArticle::class, 'article_user', 'user_id', 'article_id'); + } +} + +class BelongsToManyChunkByIdTestTestArticle extends Eloquent +{ + protected ?string $table = 'articles'; + protected string $keyType = 'string'; + public bool $incrementing = false; + public bool $timestamps = false; + protected array $fillable = ['id', 'title']; +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyCreateOrFirstTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyCreateOrFirstTest.php new file mode 100644 index 000000000..8545f8343 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyCreateOrFirstTest.php @@ -0,0 +1,507 @@ +id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + [456], + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection()->expects('insert')->with( + 'insert into "related_table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $source->getConnection()->expects('insert')->with( + 'insert into "pivot_table" ("related_id", "source_id") values (?, ?)', + [456, 123], + )->andReturnTrue(); + + $result = $source->related()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testCreateOrFirstMethodAssociatesExistingRelated(): void + { + $source = new BelongsToManyCreateOrFirstTestSourceModel(); + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $sql = 'insert into "related_table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $source->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $source->getConnection() + ->expects('select') + ->with('select * from "related_table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $source->getConnection()->expects('insert')->with( + 'insert into "pivot_table" ("related_id", "source_id") values (?, ?)', + [456, 123], + )->andReturnTrue(); + + $result = $source->related()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + // Pivot is not loaded when related model is newly created. + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRelatedAlreadyAssociated(): void + { + $source = new BelongsToManyCreateOrFirstTestSourceModel(); + $source->id = 123; + $source->exists = true; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection() + ->expects('select') + ->with( + 'select "related_table".*, "pivot_table"."source_id" as "pivot_source_id", "pivot_table"."related_id" as "pivot_related_id" from "related_table" inner join "pivot_table" on "related_table"."id" = "pivot_table"."related_id" where "pivot_table"."source_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + 'pivot_source_id' => 123, + 'pivot_related_id' => 456, + ]]); + + $result = $source->related()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + 'pivot' => [ + 'source_id' => 123, + 'related_id' => 456, + ], + ], $result->toArray()); + } + + public function testCreateOrFirstMethodRetrievesExistingRelatedAssociatedJustNow(): void + { + $source = new BelongsToManyCreateOrFirstTestSourceModel(); + $source->id = 123; + $source->exists = true; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $sql = 'insert into "related_table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $source->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $source->getConnection() + ->expects('select') + ->with('select * from "related_table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $sql = 'insert into "pivot_table" ("related_id", "source_id") values (?, ?)'; + $bindings = [456, 123]; + + $source->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $source->getConnection() + ->expects('select') + ->with( + 'select "related_table".*, "pivot_table"."source_id" as "pivot_source_id", "pivot_table"."related_id" as "pivot_related_id" from "related_table" inner join "pivot_table" on "related_table"."id" = "pivot_table"."related_id" where "pivot_table"."source_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + false, + ) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + 'pivot_source_id' => 123, + 'pivot_related_id' => 456, + ]]); + + $result = $source->related()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + 'pivot' => [ + 'source_id' => 123, + 'related_id' => 456, + ], + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRelatedAndAssociatesIt(): void + { + $source = new BelongsToManyCreateOrFirstTestSourceModel(); + $source->id = 123; + $source->exists = true; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection() + ->expects('select') + ->with( + 'select "related_table".*, "pivot_table"."source_id" as "pivot_source_id", "pivot_table"."related_id" as "pivot_related_id" from "related_table" inner join "pivot_table" on "related_table"."id" = "pivot_table"."related_id" where "pivot_table"."source_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $source->getConnection() + ->expects('select') + ->with( + 'select * from "related_table" where ("attr" = ?) limit 1', + ['foo'], + true, + ) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $source->getConnection() + ->expects('insert') + ->with( + 'insert into "pivot_table" ("related_id", "source_id") values (?, ?)', + [456, 123], + ) + ->andReturnTrue(); + + $result = $source->related()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + // Pivot is not loaded when related model is newly created. + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodFallsBackToCreateOrFirst(): void + { + $source = new class() extends BelongsToManyCreateOrFirstTestSourceModel + { + protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName = null): BelongsToMany + { + $relation = m::mock(BelongsToMany::class)->makePartial(); + $relation->__construct(...func_get_args()); + $instance = new BelongsToManyCreateOrFirstTestRelatedModel([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + 'pivot' => [ + 'source_id' => 123, + 'related_id' => 456, + ], + ]); + $instance->exists = true; + $instance->wasRecentlyCreated = false; + $instance->syncOriginal(); + $relation + ->expects('createOrFirst') + ->with(['attr' => 'foo'], ['val' => 'bar'], [], true) + ->andReturn($instance); + + return $relation; + } + }; + $source->id = 123; + $source->exists = true; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection() + ->expects('select') + ->with( + 'select "related_table".*, "pivot_table"."source_id" as "pivot_source_id", "pivot_table"."related_id" as "pivot_related_id" from "related_table" inner join "pivot_table" on "related_table"."id" = "pivot_table"."related_id" where "pivot_table"."source_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $source->getConnection() + ->expects('select') + ->with( + 'select * from "related_table" where ("attr" = ?) limit 1', + ['foo'], + true, + ) + ->andReturn([]); + + $result = $source->related()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + 'pivot' => [ + 'source_id' => 123, + 'related_id' => 456, + ], + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodCreatesNewRelated(): void + { + $source = new class() extends BelongsToManyCreateOrFirstTestSourceModel + { + protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName = null): BelongsToMany + { + $relation = m::mock(BelongsToMany::class)->makePartial(); + $relation->__construct(...func_get_args()); + $instance = new BelongsToManyCreateOrFirstTestRelatedModel([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]); + $instance->exists = true; + $instance->wasRecentlyCreated = true; + $instance->syncOriginal(); + $relation + ->expects('firstOrCreate') + ->with(['attr' => 'foo'], ['val' => 'baz'], [], true) + ->andReturn($instance); + + return $relation; + } + }; + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + + $result = $source->related()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesExistingRelated(): void + { + $source = new class() extends BelongsToManyCreateOrFirstTestSourceModel + { + protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName = null): BelongsToMany + { + $relation = m::mock(BelongsToMany::class)->makePartial(); + $relation->__construct(...func_get_args()); + $instance = new BelongsToManyCreateOrFirstTestRelatedModel([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]); + $instance->exists = true; + $instance->wasRecentlyCreated = false; + $instance->syncOriginal(); + $relation + ->expects('firstOrCreate') + ->with(['attr' => 'foo'], ['val' => 'baz'], [], true) + ->andReturn($instance); + + return $relation; + } + }; + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection() + ->expects('update') + ->with( + 'update "related_table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 456], + ) + ->andReturn(1); + + $result = $source->related()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + protected function mockConnectionForModels(array $models, string $database, array $lastInsertIds = []): void + { + $grammarClass = 'Hypervel\Database\Query\Grammars\\'.$database.'Grammar'; + $processorClass = 'Hypervel\Database\Query\Processors\\'.$database.'Processor'; + $processor = new $processorClass; + $connection = m::mock(Connection::class, ['getPostProcessor' => $processor]); + $grammar = new $grammarClass($connection); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new BaseBuilder($connection, $grammar, $processor); + }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $resolver = m::mock(ConnectionResolverInterface::class, ['connection' => $connection]); + + foreach ($models as $model) { + /** @var Model $model */ + $class = get_class($model); + $class::setConnectionResolver($resolver); + } + + $connection->shouldReceive('getPdo')->andReturn($pdo = m::mock(PDO::class)); + + foreach ($lastInsertIds as $id) { + $pdo->expects('lastInsertId')->andReturn($id); + } + } +} + +/** + * @property int $id + */ +class BelongsToManyCreateOrFirstTestRelatedModel extends Model +{ + protected ?string $table = 'related_table'; + protected array $guarded = []; +} + +/** + * @property int $id + */ +class BelongsToManyCreateOrFirstTestSourceModel extends Model +{ + protected ?string $table = 'source_table'; + protected array $guarded = []; + + public function related(): BelongsToMany + { + return $this->belongsToMany( + BelongsToManyCreateOrFirstTestRelatedModel::class, + 'pivot_table', + 'source_id', + 'related_id', + ); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyEachByIdTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyEachByIdTest.php new file mode 100644 index 000000000..2e33942b1 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyEachByIdTest.php @@ -0,0 +1,138 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + }); + + $this->schema()->create('articles', function ($table) { + $table->increments('id'); + $table->string('title'); + }); + + $this->schema()->create('article_user', function ($table) { + $table->increments('id'); + $table->integer('article_id')->unsigned(); + $table->foreign('article_id')->references('id')->on('articles'); + $table->integer('user_id')->unsigned(); + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + public function testBelongsToEachById() + { + $this->seedData(); + + $user = BelongsToManyEachByIdTestTestUser::query()->first(); + $i = 0; + + $user->articles()->eachById(function (BelongsToManyEachByIdTestTestArticle $model) use (&$i) { + $i++; + $this->assertEquals($i, $model->id); + }); + + $this->assertSame(3, $i); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = BelongsToManyEachByIdTestTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + BelongsToManyEachByIdTestTestArticle::query()->insert([ + ['id' => 1, 'title' => 'Another title'], + ['id' => 2, 'title' => 'Another title'], + ['id' => 3, 'title' => 'Another title'], + ]); + + $user->articles()->sync([3, 1, 2]); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class BelongsToManyEachByIdTestTestUser extends Eloquent +{ + protected ?string $table = 'users'; + protected array $fillable = ['id', 'email']; + public bool $timestamps = false; + + public function articles() + { + return $this->belongsToMany(BelongsToManyEachByIdTestTestArticle::class, 'article_user', 'user_id', 'article_id'); + } +} + +class BelongsToManyEachByIdTestTestArticle extends Eloquent +{ + protected ?string $table = 'articles'; + protected string $keyType = 'string'; + public bool $incrementing = false; + public bool $timestamps = false; + protected array $fillable = ['id', 'title']; +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyExpressionTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyExpressionTest.php new file mode 100644 index 000000000..fec06bf77 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyExpressionTest.php @@ -0,0 +1,182 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + public function testAmbiguousColumnsExpression(): void + { + $this->seedData(); + + $tags = DatabaseEloquentBelongsToManyExpressionTestTestPost::findOrFail(1) + ->tags() + ->wherePivotNotIn(new Expression("tag_id || '_' || type"), ['1_t1']) + ->get(); + + $this->assertCount(1, $tags); + $this->assertEquals(2, $tags->first()->getKey()); + } + + public function testQualifiedColumnExpression(): void + { + $this->seedData(); + + $tags = DatabaseEloquentBelongsToManyExpressionTestTestPost::findOrFail(2) + ->tags() + ->wherePivotNotIn(new Expression("taggables.tag_id || '_' || taggables.type"), ['2_t2']) + ->get(); + + $this->assertCount(1, $tags); + $this->assertEquals(3, $tags->first()->getKey()); + } + + public function testGlobalScopesAreAppliedToBelongsToManyRelation(): void + { + $this->seedData(); + $post = DatabaseEloquentBelongsToManyExpressionTestTestPost::query()->firstOrFail(); + DatabaseEloquentBelongsToManyExpressionTestTestTag::addGlobalScope( + 'default', + static fn () => throw new Exception('Default global scope.') + ); + + $this->expectExceptionMessage('Default global scope.'); + $post->tags()->get(); + } + + public function testGlobalScopesCanBeRemovedFromBelongsToManyRelation(): void + { + $this->seedData(); + $post = DatabaseEloquentBelongsToManyExpressionTestTestPost::query()->firstOrFail(); + DatabaseEloquentBelongsToManyExpressionTestTestTag::addGlobalScope( + 'default', + static fn () => throw new Exception('Default global scope.') + ); + + $this->assertNotEmpty($post->tags()->withoutGlobalScopes()->get()); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('posts', fn (Blueprint $t) => $t->id()); + $this->schema()->create('tags', fn (Blueprint $t) => $t->id()); + $this->schema()->create('taggables', function (Blueprint $t) { + $t->unsignedBigInteger('tag_id'); + $t->unsignedBigInteger('taggable_id'); + $t->string('type', 10); + $t->string('taggable_type'); + } + ); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('posts'); + $this->schema()->drop('tags'); + $this->schema()->drop('taggables'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData(): void + { + $p1 = DatabaseEloquentBelongsToManyExpressionTestTestPost::query()->create(); + $p2 = DatabaseEloquentBelongsToManyExpressionTestTestPost::query()->create(); + $t1 = DatabaseEloquentBelongsToManyExpressionTestTestTag::query()->create(); + $t2 = DatabaseEloquentBelongsToManyExpressionTestTestTag::query()->create(); + $t3 = DatabaseEloquentBelongsToManyExpressionTestTestTag::query()->create(); + + $p1->tags()->sync([ + $t1->getKey() => ['type' => 't1'], + $t2->getKey() => ['type' => 't2'], + ]); + $p2->tags()->sync([ + $t2->getKey() => ['type' => 't2'], + $t3->getKey() => ['type' => 't3'], + ]); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class DatabaseEloquentBelongsToManyExpressionTestTestPost extends Eloquent +{ + protected ?string $table = 'posts'; + protected array $fillable = ['id']; + public bool $timestamps = false; + + public function tags(): MorphToMany + { + return $this->morphToMany( + DatabaseEloquentBelongsToManyExpressionTestTestTag::class, + 'taggable', + 'taggables', + 'taggable_id', + 'tag_id', + 'id', + 'id', + ); + } +} + +class DatabaseEloquentBelongsToManyExpressionTestTestTag extends Eloquent +{ + protected ?string $table = 'tags'; + protected array $fillable = ['id']; + public bool $timestamps = false; +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyLazyByIdTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyLazyByIdTest.php new file mode 100644 index 000000000..4f929af7e --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyLazyByIdTest.php @@ -0,0 +1,138 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + }); + + $this->schema()->create('articles', function ($table) { + $table->increments('aid'); + $table->string('title'); + }); + + $this->schema()->create('article_user', function ($table) { + $table->integer('article_id')->unsigned(); + $table->foreign('article_id')->references('aid')->on('articles'); + $table->integer('user_id')->unsigned(); + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + public function testBelongsToLazyById() + { + $this->seedData(); + + $user = BelongsToManyLazyByIdTestTestUser::query()->first(); + $i = 0; + + $user->articles()->lazyById(1)->each(function ($model) use (&$i) { + $i++; + $this->assertEquals($i, $model->aid); + }); + + $this->assertSame(3, $i); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = BelongsToManyLazyByIdTestTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + BelongsToManyLazyByIdTestTestArticle::query()->insert([ + ['aid' => 1, 'title' => 'Another title'], + ['aid' => 2, 'title' => 'Another title'], + ['aid' => 3, 'title' => 'Another title'], + ]); + + $user->articles()->sync([3, 1, 2]); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class BelongsToManyLazyByIdTestTestUser extends Eloquent +{ + protected ?string $table = 'users'; + protected array $fillable = ['id', 'email']; + public bool $timestamps = false; + + public function articles() + { + return $this->belongsToMany(BelongsToManyLazyByIdTestTestArticle::class, 'article_user', 'user_id', 'article_id'); + } +} + +class BelongsToManyLazyByIdTestTestArticle extends Eloquent +{ + protected string $primaryKey = 'aid'; + protected ?string $table = 'articles'; + protected string $keyType = 'string'; + public bool $incrementing = false; + public bool $timestamps = false; + protected array $fillable = ['aid', 'title']; +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManySyncReturnValueTypeTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManySyncReturnValueTypeTest.php new file mode 100644 index 000000000..1a37cc2f4 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManySyncReturnValueTypeTest.php @@ -0,0 +1,159 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + }); + + $this->schema()->create('articles', function ($table) { + $table->string('id'); + $table->string('title'); + + $table->primary('id'); + }); + + $this->schema()->create('article_user', function ($table) { + $table->string('article_id'); + $table->foreign('article_id')->references('id')->on('articles'); + $table->integer('user_id')->unsigned(); + $table->foreign('user_id')->references('id')->on('users'); + $table->boolean('visible')->default(false); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + BelongsToManySyncTestTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + BelongsToManySyncTestTestArticle::insert([ + ['id' => '7b7306ae-5a02-46fa-a84c-9538f45c7dd4', 'title' => 'uuid title'], + ['id' => (string) (PHP_INT_MAX + 1), 'title' => 'Another title'], + ['id' => '1', 'title' => 'Another title'], + ]); + } + + public function testSyncReturnValueType() + { + $this->seedData(); + + $user = BelongsToManySyncTestTestUser::query()->first(); + $articleIDs = BelongsToManySyncTestTestArticle::all()->pluck('id')->toArray(); + + $changes = $user->articles()->sync($articleIDs); + + collect($changes['attached'])->map(function ($id) { + $this->assertSame(gettype($id), (new BelongsToManySyncTestTestArticle)->getKeyType()); + }); + + $user->articles->each(function (BelongsToManySyncTestTestArticle $article) { + $this->assertSame('0', (string) $article->pivot->visible); + }); + } + + public function testSyncWithPivotDefaultsReturnValueType() + { + $this->seedData(); + + $user = BelongsToManySyncTestTestUser::query()->first(); + $articleIDs = BelongsToManySyncTestTestArticle::all()->pluck('id')->toArray(); + + $changes = $user->articles()->syncWithPivotValues($articleIDs, ['visible' => true]); + + collect($changes['attached'])->each(function ($id) { + $this->assertSame(gettype($id), (new BelongsToManySyncTestTestArticle)->getKeyType()); + }); + + $user->articles->each(function (BelongsToManySyncTestTestArticle $article) { + $this->assertSame('1', (string) $article->pivot->visible); + }); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class BelongsToManySyncTestTestUser extends Eloquent +{ + protected ?string $table = 'users'; + protected array $fillable = ['id', 'email']; + public bool $timestamps = false; + + public function articles() + { + return $this->belongsToMany(BelongsToManySyncTestTestArticle::class, 'article_user', 'user_id', 'article_id')->withPivot('visible'); + } +} + +class BelongsToManySyncTestTestArticle extends Eloquent +{ + protected ?string $table = 'articles'; + protected string $keyType = 'string'; + public bool $incrementing = false; + public bool $timestamps = false; + protected array $fillable = ['id', 'title']; +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManySyncTouchesParentTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManySyncTouchesParentTest.php new file mode 100644 index 000000000..67d730257 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManySyncTouchesParentTest.php @@ -0,0 +1,177 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('articles', function ($table) { + $table->string('id'); + $table->string('title'); + + $table->primary('id'); + $table->timestamps(); + }); + + $this->schema()->create('article_user', function ($table) { + $table->string('article_id'); + $table->foreign('article_id')->references('id')->on('articles'); + $table->integer('user_id')->unsigned(); + $table->foreign('user_id')->references('id')->on('users'); + $table->timestamps(); + }); + + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::create(['id' => 2, 'email' => 'anonymous@gmail.com']); + DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::create(['id' => 3, 'email' => 'anoni-mous@gmail.com']); + } + + public function testSyncWithDetachedValuesShouldTouch() + { + $this->seedData(); + + Carbon::setTestNow('2021-07-19 10:13:14'); + $article = DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle::create(['id' => 1, 'title' => 'uuid title']); + $article->users()->sync([1, 2, 3]); + $this->assertSame('2021-07-19 10:13:14', $article->updated_at->format('Y-m-d H:i:s')); + + Carbon::setTestNow('2021-07-20 19:13:14'); + $result = $article->users()->sync([1, 2]); + $this->assertCount(1, collect($result['detached'])); + $this->assertSame('3', (string) collect($result['detached'])->first()); + + $article->refresh(); + $this->assertSame('2021-07-20 19:13:14', $article->updated_at->format('Y-m-d H:i:s')); + + $user1 = DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::find(1); + $this->assertNotSame('2021-07-20 19:13:14', $user1->updated_at->format('Y-m-d H:i:s')); + $user2 = DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::find(2); + $this->assertNotSame('2021-07-20 19:13:14', $user2->updated_at->format('Y-m-d H:i:s')); + $user3 = DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::find(3); + $this->assertNotSame('2021-07-20 19:13:14', $user3->updated_at->format('Y-m-d H:i:s')); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle extends Eloquent +{ + protected ?string $table = 'articles'; + protected string $keyType = 'string'; + public bool $incrementing = false; + protected array $fillable = ['id', 'title']; + + public function users() + { + return $this + ->belongsToMany(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle::class, 'article_user', 'article_id', 'user_id') + ->using(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticleUser::class) + ->withTimestamps(); + } +} + +class DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticleUser extends EloquentPivot +{ + protected ?string $table = 'article_user'; + protected array $fillable = ['article_id', 'user_id']; + protected array $touches = ['article']; + + public function article() + { + return $this->belongsTo(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle::class, 'article_id', 'id'); + } + + public function user() + { + return $this->belongsTo(DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser::class, 'user_id', 'id'); + } +} + +class DatabaseEloquentBelongsToManySyncTouchesParentTestTestUser extends Eloquent +{ + protected ?string $table = 'users'; + protected string $keyType = 'string'; + public bool $incrementing = false; + protected array $fillable = ['id', 'email']; + + public function articles() + { + return $this + ->belongsToMany(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticle::class, 'article_user', 'user_id', 'article_id') + ->using(DatabaseEloquentBelongsToManySyncTouchesParentTestTestArticleUser::class) + ->withTimestamps(); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesPendingTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesPendingTest.php new file mode 100644 index 000000000..c921fdf2b --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesPendingTest.php @@ -0,0 +1,260 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + $this->createSchema(); + } + + public function testCreatesPendingAttributesAndPivotValues(): void + { + $post = ManyToManyPendingAttributesPost::create(); + $tag = $post->metaTags()->create(['name' => 'long article']); + + $this->assertSame('long article', $tag->name); + $this->assertTrue($tag->visible); + + $pivot = DB::table('pending_attributes_pivot')->first(); + $this->assertSame('meta', $pivot->type); + $this->assertSame($post->id, $pivot->post_id); + $this->assertSame($tag->id, $pivot->tag_id); + } + + public function testQueriesPendingAttributesAndPivotValues(): void + { + $post = new ManyToManyPendingAttributesPost(['id' => 2]); + $wheres = $post->metaTags()->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'pending_attributes_pivot.tag_id', + 'operator' => '=', + 'value' => 2, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'pending_attributes_pivot.type', + 'operator' => '=', + 'value' => 'meta', + 'boolean' => 'and', + ], $wheres); + + // Ensure no other wheres exist + $this->assertCount(2, $wheres); + } + + public function testMorphToManyPendingAttributes(): void + { + $post = new ManyToManyPendingAttributesPost(['id' => 2]); + $wheres = $post->morphedTags()->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'pending_attributes_taggables.type', + 'operator' => '=', + 'value' => 'meta', + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'pending_attributes_taggables.taggable_type', + 'operator' => '=', + 'value' => ManyToManyPendingAttributesPost::class, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'pending_attributes_taggables.taggable_id', + 'operator' => '=', + 'value' => 2, + 'boolean' => 'and', + ], $wheres); + + // Ensure no other wheres exist + $this->assertCount(3, $wheres); + + $tag = $post->morphedTags()->create(['name' => 'new tag']); + + $this->assertTrue($tag->visible); + $this->assertSame('new tag', $tag->name); + $this->assertSame($tag->id, $post->morphedTags()->first()->id); + } + + public function testMorphedByManyPendingAttributes(): void + { + $tag = new ManyToManyPendingAttributesTag(['id' => 4]); + $wheres = $tag->morphedPosts()->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'pending_attributes_taggables.type', + 'operator' => '=', + 'value' => 'meta', + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'pending_attributes_taggables.taggable_type', + 'operator' => '=', + 'value' => ManyToManyPendingAttributesPost::class, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'pending_attributes_taggables.tag_id', + 'operator' => '=', + 'value' => 4, + 'boolean' => 'and', + ], $wheres); + + // Ensure no other wheres exist + $this->assertCount(3, $wheres); + + $post = $tag->morphedPosts()->create(); + $this->assertSame('Title!', $post->title); + $this->assertSame($post->id, $tag->morphedPosts()->first()->id); + } + + protected function createSchema() + { + $this->schema()->create('pending_attributes_posts', function ($table) { + $table->increments('id'); + $table->string('title')->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('pending_attributes_tags', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->boolean('visible')->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('pending_attributes_pivot', function ($table) { + $table->integer('post_id'); + $table->integer('tag_id'); + $table->string('type'); + }); + + $this->schema()->create('pending_attributes_taggables', function ($table) { + $table->integer('tag_id'); + $table->integer('taggable_id'); + $table->string('taggable_type'); + $table->string('type'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('pending_attributes_posts'); + $this->schema()->drop('pending_attributes_tags'); + $this->schema()->drop('pending_attributes_pivot'); + + parent::tearDown(); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Model::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class ManyToManyPendingAttributesPost extends Model +{ + protected array $guarded = []; + protected ?string $table = 'pending_attributes_posts'; + + public function tags(): BelongsToMany + { + return $this->belongsToMany( + ManyToManyPendingAttributesTag::class, + 'pending_attributes_pivot', + 'tag_id', + 'post_id', + ); + } + + public function metaTags(): BelongsToMany + { + return $this->tags() + ->withAttributes('visible', true, asConditions: false) + ->withPivotValue('type', 'meta'); + } + + public function morphedTags(): MorphToMany + { + return $this + ->morphToMany( + ManyToManyPendingAttributesTag::class, + 'taggable', + 'pending_attributes_taggables', + relatedPivotKey: 'tag_id' + ) + ->withAttributes('visible', true, asConditions: false) + ->withPivotValue('type', 'meta'); + } +} + +class ManyToManyPendingAttributesTag extends Model +{ + protected array $guarded = []; + protected ?string $table = 'pending_attributes_tags'; + + public function morphedPosts(): MorphToMany + { + return $this + ->morphedByMany( + ManyToManyPendingAttributesPost::class, + 'taggable', + 'pending_attributes_taggables', + 'tag_id', + ) + ->withAttributes('title', 'Title!', asConditions: false) + ->withPivotValue('type', 'meta'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesTest.php new file mode 100755 index 000000000..9be949cff --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesTest.php @@ -0,0 +1,267 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + $this->createSchema(); + } + + public function testCreatesWithAttributesAndPivotValues(): void + { + $post = ManyToManyWithAttributesPost::create(); + $tag = $post->metaTags()->create(['name' => 'long article']); + + $this->assertSame('long article', $tag->name); + $this->assertTrue($tag->visible); + + $pivot = DB::table('with_attributes_pivot')->first(); + $this->assertSame('meta', $pivot->type); + $this->assertSame($post->id, $pivot->post_id); + $this->assertSame($tag->id, $pivot->tag_id); + } + + public function testQueriesWithAttributesAndPivotValues(): void + { + $post = new ManyToManyWithAttributesPost(['id' => 2]); + $wheres = $post->metaTags()->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_tags.visible', + 'operator' => '=', + 'value' => true, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_pivot.type', + 'operator' => '=', + 'value' => 'meta', + 'boolean' => 'and', + ], $wheres); + } + + public function testMorphToManyWithAttributes(): void + { + $post = new ManyToManyWithAttributesPost(['id' => 2]); + $wheres = $post->morphedTags()->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_tags.visible', + 'operator' => '=', + 'value' => true, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.type', + 'operator' => '=', + 'value' => 'meta', + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.taggable_type', + 'operator' => '=', + 'value' => ManyToManyWithAttributesPost::class, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.taggable_id', + 'operator' => '=', + 'value' => 2, + 'boolean' => 'and', + ], $wheres); + + $tag = $post->morphedTags()->create(['name' => 'new tag']); + + $this->assertTrue($tag->visible); + $this->assertSame('new tag', $tag->name); + $this->assertSame($tag->id, $post->morphedTags()->first()->id); + } + + public function testMorphedByManyWithAttributes(): void + { + $tag = new ManyToManyWithAttributesTag(['id' => 4]); + $wheres = $tag->morphedPosts()->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_posts.title', + 'operator' => '=', + 'value' => 'Title!', + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.type', + 'operator' => '=', + 'value' => 'meta', + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.taggable_type', + 'operator' => '=', + 'value' => ManyToManyWithAttributesPost::class, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.tag_id', + 'operator' => '=', + 'value' => 4, + 'boolean' => 'and', + ], $wheres); + + $post = $tag->morphedPosts()->create(); + $this->assertSame('Title!', $post->title); + $this->assertSame($post->id, $tag->morphedPosts()->first()->id); + } + + protected function createSchema() + { + $this->schema()->create('with_attributes_posts', function ($table) { + $table->increments('id'); + $table->string('title')->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('with_attributes_tags', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->boolean('visible')->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('with_attributes_pivot', function ($table) { + $table->integer('post_id'); + $table->integer('tag_id'); + $table->string('type'); + }); + + $this->schema()->create('with_attributes_taggables', function ($table) { + $table->integer('tag_id'); + $table->integer('taggable_id'); + $table->string('taggable_type'); + $table->string('type'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('with_attributes_posts'); + $this->schema()->drop('with_attributes_tags'); + $this->schema()->drop('with_attributes_pivot'); + + parent::tearDown(); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Model::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class ManyToManyWithAttributesPost extends Model +{ + protected array $guarded = []; + protected ?string $table = 'with_attributes_posts'; + + public function tags(): BelongsToMany + { + return $this->belongsToMany( + ManyToManyWithAttributesTag::class, + 'with_attributes_pivot', + 'tag_id', + 'post_id', + ); + } + + public function metaTags(): BelongsToMany + { + return $this->tags() + ->withAttributes('visible', true) + ->withPivotValue('type', 'meta'); + } + + public function morphedTags(): MorphToMany + { + return $this + ->morphToMany( + ManyToManyWithAttributesTag::class, + 'taggable', + 'with_attributes_taggables', + relatedPivotKey: 'tag_id' + ) + ->withAttributes('visible', true) + ->withPivotValue('type', 'meta'); + } +} + +class ManyToManyWithAttributesTag extends Model +{ + protected array $guarded = []; + protected ?string $table = 'with_attributes_tags'; + + public function morphedPosts(): MorphToMany + { + return $this + ->morphedByMany( + ManyToManyWithAttributesPost::class, + 'taggable', + 'with_attributes_taggables', + 'tag_id', + ) + ->withAttributes('title', 'Title!') + ->withPivotValue('type', 'meta'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php new file mode 100644 index 000000000..4e213e130 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php @@ -0,0 +1,87 @@ +getRelation(); + $model1 = m::mock(Model::class); + $model1->shouldReceive('hasAttribute')->passthru(); + $model1->shouldReceive('getAttribute')->with('parent_key')->andReturn(1); + $model1->shouldReceive('getAttribute')->with('foo')->passthru(); + $model1->shouldReceive('hasGetMutator')->andReturn(false); + $model1->shouldReceive('hasAttributeMutator')->andReturn(false); + $model1->shouldReceive('hasRelationAutoloadCallback')->andReturn(false); + $model1->shouldReceive('getCasts')->andReturn([]); + $model1->shouldReceive('getRelationValue', 'relationLoaded', 'relationResolver', 'setRelation', 'isRelation')->passthru(); + + $model2 = m::mock(Model::class); + $model2->shouldReceive('hasAttribute')->passthru(); + $model2->shouldReceive('getAttribute')->with('parent_key')->andReturn(2); + $model2->shouldReceive('getAttribute')->with('foo')->passthru(); + $model2->shouldReceive('hasGetMutator')->andReturn(false); + $model2->shouldReceive('hasAttributeMutator')->andReturn(false); + $model2->shouldReceive('hasRelationAutoloadCallback')->andReturn(false); + $model2->shouldReceive('getCasts')->andReturn([]); + $model2->shouldReceive('getRelationValue', 'relationLoaded', 'relationResolver', 'setRelation', 'isRelation')->passthru(); + + $result1 = (object) [ + 'pivot' => (object) [ + 'foreign_key' => new class + { + public function __toString() + { + return '1'; + } + }, + ], + ]; + + $models = $relation->match([$model1, $model2], Collection::wrap($result1), 'foo'); + $this->assertNull($models[1]->foo); + $this->assertSame(1, $models[0]->foo->count()); + $this->assertContains($result1, $models[0]->foo); + } + + protected function getRelation() + { + $builder = m::mock(Builder::class); + $related = m::mock(Model::class); + $related->shouldReceive('newCollection')->passthru(); + $related->shouldReceive('resolveCollectionFromAttribute')->passthru(); + $builder->shouldReceive('getModel')->andReturn($related); + $related->shouldReceive('qualifyColumn'); + $builder->shouldReceive('join', 'where'); + $builder->shouldReceive('getQuery')->andReturn( + m::mock(stdClass::class, ['getGrammar' => m::mock(Grammar::class, ['isExpression' => false])]) + ); + + return new BelongsToMany( + $builder, + new EloquentBelongsToManyModelStub, + 'relation', + 'foreign_key', + 'id', + 'parent_key', + 'related_key' + ); + } +} + +class EloquentBelongsToManyModelStub extends Model +{ + public $foreign_key = 'foreign.value'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php new file mode 100644 index 000000000..3212bb522 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php @@ -0,0 +1,62 @@ +getMockBuilder(BelongsToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $relation->withPivotValue(['is_admin' => 1]); + } + + public function testWithPivotValueMethodSetsDefaultArgumentsForInsertion() + { + $relation = $this->getMockBuilder(BelongsToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $relation->withPivotValue(['is_admin' => 1]); + + $query = m::mock(QueryBuilder::class); + $query->shouldReceive('from')->once()->with('club_user')->andReturn($query); + $query->shouldReceive('insert')->once()->with([['club_id' => 1, 'user_id' => 1, 'is_admin' => 1]])->andReturn(true); + $relation->getQuery()->getQuery()->shouldReceive('newQuery')->once()->andReturn($query); + + $relation->attach(1); + } + + public function getRelationArguments() + { + $parent = m::mock(Model::class); + $parent->shouldReceive('getKey')->andReturn(1); + $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); + $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + + $builder = m::mock(Builder::class); + $related = m::mock(Model::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $related->shouldReceive('getTable')->andReturn('users'); + $related->shouldReceive('getKeyName')->andReturn('id'); + $related->shouldReceive('qualifyColumn')->with('id')->andReturn('users.id'); + + $builder->shouldReceive('join')->once()->with('club_user', 'users.id', '=', 'club_user.user_id'); + $builder->shouldReceive('where')->once()->with('club_user.club_id', '=', 1)->andReturnSelf(); + $builder->shouldReceive('where')->once()->with('club_user.is_admin', '=', 1, 'and')->andReturnSelf(); + + $builder->shouldReceive('getQuery')->andReturn($mockQueryBuilder = m::mock(stdClass::class)); + $mockQueryBuilder->shouldReceive('getGrammar')->andReturn(m::mock(Grammar::class, ['isExpression' => false])); + + return [$builder, $parent, 'club_user', 'club_id', 'user_id', 'id', 'id', null, false]; + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithoutTouchingTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithoutTouchingTest.php new file mode 100644 index 000000000..1bdfeadc0 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithoutTouchingTest.php @@ -0,0 +1,70 @@ +makePartial(); + $related->shouldReceive('getUpdatedAtColumn')->never(); + $related->shouldReceive('freshTimestampString')->never(); + + $this->assertFalse($related::isIgnoringTouch()); + + Model::withoutTouching(function () use ($related) { + $this->assertTrue($related::isIgnoringTouch()); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('join'); + $parent = m::mock(User::class); + + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('where'); + $builder->shouldReceive('getQuery')->andReturn( + m::mock(stdClass::class, ['getGrammar' => m::mock(Grammar::class, ['isExpression' => false])]) + ); + $relation = new BelongsToMany($builder, $parent, 'article_users', 'user_id', 'article_id', 'id', 'id'); + $builder->shouldReceive('update')->never(); + + $relation->touch(); + }); + + $this->assertFalse($related::isIgnoringTouch()); + } +} + +class User extends Model +{ + protected ?string $table = 'users'; + protected array $fillable = ['id', 'email']; + + public function articles(): BelongsToMany + { + return $this->belongsToMany(Article::class, 'article_user', 'user_id', 'article_id'); + } +} + +class Article extends Model +{ + protected ?string $table = 'articles'; + protected array $fillable = ['id', 'title']; + protected array $touches = ['user']; + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'article_user', 'article_id', 'user_id'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToTest.php new file mode 100755 index 000000000..8c5a91b31 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToTest.php @@ -0,0 +1,462 @@ +getRelationWithPartialMock()->withDefault(); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + $this->related->shouldReceive('newInstance')->once()->andReturnSelf(); + + $result = $relation->getResults(); + + $this->assertSame($this->related, $result); + } + + public function testBelongsToWithDynamicDefault() + { + $relation = $this->getRelationWithPartialMock()->withDefault(function ($newModel) { + $newModel->username = 'taylor'; + }); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + $this->related->shouldReceive('newInstance')->once()->andReturnSelf(); + + $result = $relation->getResults(); + + $this->assertSame($this->related, $result); + // Partial mock has real Model attribute behavior, so this actually tests the callback worked + $this->assertSame('taylor', $result->username); + } + + public function testBelongsToWithArrayDefault() + { + $relation = $this->getRelationWithPartialMock()->withDefault(['username' => 'taylor']); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + $this->related->shouldReceive('newInstance')->once()->andReturnSelf(); + + $result = $relation->getResults(); + + $this->assertSame($this->related, $result); + // Partial mock has real Model attribute behavior, so this actually tests forceFill worked + $this->assertSame('taylor', $result->username); + } + + public function testEagerConstraintsAreProperlyAdded() + { + $relation = $this->getRelation(); + $relation->getRelated()->shouldReceive('getKeyName')->andReturn('id'); + $relation->getRelated()->shouldReceive('getKeyType')->andReturn('int'); + $relation->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('relation.id', ['foreign.value', 'foreign.value.two']); + $models = [new EloquentBelongsToModelStub, new EloquentBelongsToModelStub, new AnotherEloquentBelongsToModelStub]; + $relation->addEagerConstraints($models); + } + + public function testIdsInEagerConstraintsCanBeZero() + { + $relation = $this->getRelation(); + $relation->getRelated()->shouldReceive('getKeyName')->andReturn('id'); + $relation->getRelated()->shouldReceive('getKeyType')->andReturn('int'); + $relation->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('relation.id', [0, 'foreign.value']); + $models = [new EloquentBelongsToModelStub, new EloquentBelongsToModelStubWithZeroId]; + $relation->addEagerConstraints($models); + } + + public function testIdsInEagerConstraintsCanBeBackedEnum() + { + $relation = $this->getRelation(); + $relation->getRelated()->shouldReceive('getKeyName')->andReturn('id'); + $relation->getRelated()->shouldReceive('getKeyType')->andReturn('int'); + $relation->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('relation.id', [5, 'foreign.value']); + $models = [new EloquentBelongsToModelStub, new EloquentBelongsToModelStubWithBackedEnumCast]; + $relation->addEagerConstraints($models); + } + + public function testRelationIsProperlyInitialized() + { + $relation = $this->getRelation(); + $model = m::mock(Model::class); + $model->shouldReceive('setRelation')->once()->with('foo', null); + $models = $relation->initRelation([$model], 'foo'); + + $this->assertEquals([$model], $models); + } + + public function testModelsAreProperlyMatchedToParents() + { + $relation = $this->getRelation(); + + $result1 = new class extends Model + { + protected array $attributes = ['id' => 1]; + }; + + $result2 = new class extends Model + { + protected array $attributes = ['id' => 2]; + }; + + $result3 = new class extends Model + { + protected array $attributes = ['id' => 3]; + + public function __toString() + { + return '3'; + } + }; + + $result4 = new class extends Model + { + protected array $casts = [ + 'id' => Bar::class, + ]; + + protected array $attributes = ['id' => 5]; + }; + + $model1 = new EloquentBelongsToModelStub; + $model1->foreign_key = 1; + $model2 = new EloquentBelongsToModelStub; + $model2->foreign_key = 2; + $model3 = new EloquentBelongsToModelStub; + $model3->foreign_key = new class + { + public function __toString() + { + return '3'; + } + }; + $model4 = new EloquentBelongsToModelStub; + $model4->foreign_key = 5; + $models = $relation->match( + [$model1, $model2, $model3, $model4], + new Collection([$result1, $result2, $result3, $result4]), + 'foo' + ); + + $this->assertEquals(1, $models[0]->foo->getAttribute('id')); + $this->assertEquals(2, $models[1]->foo->getAttribute('id')); + $this->assertSame('3', (string) $models[2]->foo->getAttribute('id')); + $this->assertEquals(5, $models[3]->foo->getAttribute('id')->value); + } + + public function testAssociateMethodSetsForeignKeyOnModel() + { + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + $relation = $this->getRelation($parent); + $associate = m::mock(Model::class); + $associate->shouldReceive('getAttribute')->once()->with('id')->andReturn(1); + $parent->shouldReceive('setAttribute')->once()->with('foreign_key', 1); + $parent->shouldReceive('setRelation')->once()->with('relation', $associate); + + $relation->associate($associate); + } + + public function testDissociateMethodUnsetsForeignKeyOnModel() + { + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + $relation = $this->getRelation($parent); + $parent->shouldReceive('setAttribute')->once()->with('foreign_key', null); + + // Always set relation when we received Model + $parent->shouldReceive('setRelation')->once()->with('relation', null); + + $relation->dissociate(); + } + + public function testAssociateMethodSetsForeignKeyOnModelById() + { + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + $relation = $this->getRelation($parent); + $parent->shouldReceive('setAttribute')->once()->with('foreign_key', 1); + + // Always unset relation when we received id, regardless of dirtiness + $parent->shouldReceive('isDirty')->never(); + $parent->shouldReceive('unsetRelation')->once()->with($relation->getRelationName()); + + $relation->associate(1); + } + + public function testDefaultEagerConstraintsWhenIncrementing() + { + $relation = $this->getRelation(); + $relation->getRelated()->shouldReceive('getKeyName')->andReturn('id'); + $relation->getRelated()->shouldReceive('getKeyType')->andReturn('int'); + $relation->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('relation.id', m::mustBe([])); + $models = [new MissingEloquentBelongsToModelStub, new MissingEloquentBelongsToModelStub]; + $relation->addEagerConstraints($models); + } + + public function testDefaultEagerConstraintsWhenIncrementingAndNonIntKeyType() + { + $relation = $this->getRelation(null, 'string'); + $relation->getQuery()->shouldReceive('whereIn')->once()->with('relation.id', m::mustBe([])); + $models = [new MissingEloquentBelongsToModelStub, new MissingEloquentBelongsToModelStub]; + $relation->addEagerConstraints($models); + } + + public function testDefaultEagerConstraintsWhenNotIncrementing() + { + $relation = $this->getRelation(); + $relation->getRelated()->shouldReceive('getKeyName')->andReturn('id'); + $relation->getRelated()->shouldReceive('getKeyType')->andReturn('int'); + $relation->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('relation.id', m::mustBe([])); + $models = [new MissingEloquentBelongsToModelStub, new MissingEloquentBelongsToModelStub]; + $relation->addEagerConstraints($models); + } + + public function testIsNotNull() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is(null)); + } + + public function testIsModel() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerParentKey() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return an integer + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('1'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerRelatedKey() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return a string + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('1'); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerKeys() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return an integer + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsNotModelWithNullParentKey() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return null + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(null); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithNullRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(null); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value.two'); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherTable() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('table.two'); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherConnection() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation.two'); + + $this->assertFalse($relation->is($model)); + } + + protected function getRelation($parent = null, $keyType = 'int') + { + $this->builder = m::mock(Builder::class); + $this->builder->shouldReceive('where')->with('relation.id', '=', 'foreign.value'); + $this->related = m::mock(Model::class); + $this->related->shouldReceive('getKeyType')->andReturn($keyType); + $this->related->shouldReceive('getKeyName')->andReturn('id'); + $this->related->shouldReceive('getTable')->andReturn('relation'); + $this->related->shouldReceive('qualifyColumn')->andReturnUsing(fn (string $column) => "relation.{$column}"); + $this->builder->shouldReceive('getModel')->andReturn($this->related); + $parent = $parent ?: new EloquentBelongsToModelStub; + + return new BelongsTo($this->builder, $parent, 'foreign_key', 'id', 'relation'); + } + + /** + * Get relation with a partial mock for the related model. + * + * Used for withDefault tests that need real Model attribute behavior. + * The partial mock satisfies strict `static` return types on newInstance() + * while retaining real __set/__get behavior for attribute assertions. + */ + protected function getRelationWithPartialMock($parent = null) + { + $this->builder = m::mock(Builder::class); + $this->builder->shouldReceive('where')->with('relation.id', '=', 'foreign.value'); + $this->related = m::mock(EloquentBelongsToModelStub::class)->makePartial(); + $this->related->shouldReceive('getKeyType')->andReturn('int'); + $this->related->shouldReceive('getKeyName')->andReturn('id'); + $this->related->shouldReceive('getTable')->andReturn('relation'); + $this->related->shouldReceive('qualifyColumn')->andReturnUsing(fn (string $column) => "relation.{$column}"); + $this->builder->shouldReceive('getModel')->andReturn($this->related); + $parent = $parent ?: new EloquentBelongsToModelStub; + + return new BelongsTo($this->builder, $parent, 'foreign_key', 'id', 'relation'); + } +} + +class EloquentBelongsToModelStub extends Model +{ + public $foreign_key = 'foreign.value'; +} + +class AnotherEloquentBelongsToModelStub extends Model +{ + public $foreign_key = 'foreign.value.two'; +} + +class EloquentBelongsToModelStubWithZeroId extends Model +{ + public $foreign_key = 0; +} + +class MissingEloquentBelongsToModelStub extends Model +{ + public $foreign_key; +} + +class EloquentBelongsToModelStubWithBackedEnumCast extends Model +{ + protected array $casts = [ + 'foreign_key' => Bar::class, + ]; + + protected array $attributes = [ + 'foreign_key' => 5, + ]; +} diff --git a/tests/Database/Laravel/DatabaseEloquentBuilderCreateOrFirstTest.php b/tests/Database/Laravel/DatabaseEloquentBuilderCreateOrFirstTest.php new file mode 100755 index 000000000..9254ec569 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBuilderCreateOrFirstTest.php @@ -0,0 +1,508 @@ +mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->newQuery()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testCreateOrFirstMethodRetrievesExistingRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $sql = 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], false) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->newQuery()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->newQuery()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodCreatesNewRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->newQuery()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $sql = 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], false) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->newQuery()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesExistingRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection() + ->expects('update') + ->with( + 'update "table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 123], + ) + ->andReturn(1); + + $result = $model->newQuery()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodCreatesNewRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->newQuery()->updateOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $sql = 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'baz', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], false) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection() + ->expects('update') + ->with( + 'update "table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 123], + ) + ->andReturn(1); + + $result = $model->newQuery()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testIncrementOrCreateMethodIncrementsExistingRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'count' => 1, + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection() + ->expects('raw') + ->with('"count" + 1') + ->andReturn(new Expression('2')); + + $model->getConnection() + ->expects('update') + ->with( + 'update "table" set "count" = 2, "updated_at" = ? where "id" = ?', + ['2023-01-01 00:00:00', 123], + ) + ->andReturn(1); + + $result = $model->newQuery()->incrementOrCreate(['attr' => 'foo'], 'count'); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'count' => 2, + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testIncrementOrCreateMethodCreatesNewRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "count", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', '1', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->newQuery()->incrementOrCreate(['attr' => 'foo']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'count' => 1, + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testIncrementOrCreateMethodIncrementParametersArePassed(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'count' => 1, + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection() + ->expects('raw') + ->with('"count" + 2') + ->andReturn(new Expression('3')); + + $model->getConnection() + ->expects('update') + ->with( + 'update "table" set "count" = 3, "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 123], + ) + ->andReturn(1); + + $result = $model->newQuery()->incrementOrCreate(['attr' => 'foo'], step: 2, extra: ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'count' => 3, + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testIncrementOrCreateMethodRetrievesRecordCreatedJustNow(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $sql = 'insert into "table" ("attr", "count", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', '1', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], false) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'count' => 1, + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection() + ->expects('raw') + ->with('"count" + 1') + ->andReturn(new Expression('2')); + + $model->getConnection() + ->expects('update') + ->with( + 'update "table" set "count" = 2, "updated_at" = ? where "id" = ?', + ['2023-01-01 00:00:00', 123], + ) + ->andReturn(1); + + $result = $model->newQuery()->incrementOrCreate(['attr' => 'foo']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'count' => 2, + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void + { + $grammarClass = 'Hypervel\Database\Query\Grammars\\'.$database.'Grammar'; + $processorClass = 'Hypervel\Database\Query\Processors\\'.$database.'Processor'; + $processor = new $processorClass; + $connection = m::mock(Connection::class, ['getPostProcessor' => $processor]); + $grammar = new $grammarClass($connection); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new Builder($connection, $grammar, $processor); + }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $resolver = m::mock(ConnectionResolverInterface::class, ['connection' => $connection]); + + $class = get_class($model); + $class::setConnectionResolver($resolver); + + $connection->shouldReceive('getPdo')->andReturn($pdo = m::mock(PDO::class)); + + foreach ($lastInsertIds as $id) { + $pdo->expects('lastInsertId')->andReturn($id); + } + } +} + +class EloquentBuilderCreateOrFirstTestModel extends Model +{ + protected ?string $table = 'table'; + protected array $guarded = []; +} diff --git a/tests/Database/Laravel/DatabaseEloquentBuilderTest.php b/tests/Database/Laravel/DatabaseEloquentBuilderTest.php new file mode 100755 index 000000000..47ec6ccfa --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBuilderTest.php @@ -0,0 +1,3149 @@ +getMockQueryBuilder()]); + $model = $this->getMockModel(); + $builder->setModel($model); + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $builder->getQuery()->shouldReceive('where')->once()->with('foo_table.foo', '=', 'bar'); + $builder->shouldReceive('first')->with(['column'])->andReturn('baz'); + + $result = $builder->find('bar', ['column']); + $this->assertSame('baz', $result); + } + + public function testFindSoleMethod() + { + $builder = m::mock(Builder::class.'[sole]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $builder->setModel($model); + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $builder->getQuery()->shouldReceive('where')->once()->with('foo_table.foo', '=', 'bar'); + $builder->shouldReceive('sole')->with(['column'])->andReturn('baz'); + + $result = $builder->findSole('bar', ['column']); + $this->assertSame('baz', $result); + } + + public function testFindManyMethod() + { + // ids are not empty + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->andReturn('int'); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('foo_table.foo', ['one', 'two']); + $builder->shouldReceive('get')->with(['column'])->andReturn(['baz']); + + $result = $builder->findMany(['one', 'two'], ['column']); + $this->assertEquals(['baz'], $result); + + // ids are empty array + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $emptyCollection = new Collection(); + $model->shouldReceive('newCollection')->once()->withNoArgs()->andReturn($emptyCollection); + $model->shouldReceive('getKeyType')->andReturn('int'); + $builder->setModel($model); + $builder->getQuery()->shouldNotReceive('whereIntegerInRaw'); + $builder->shouldNotReceive('get'); + + $result = $builder->findMany([], ['column']); + $this->assertSame($emptyCollection, $result); + + // ids are empty collection + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $emptyCollection2 = new Collection(); + $model->shouldReceive('newCollection')->once()->withNoArgs()->andReturn($emptyCollection2); + $builder->setModel($model); + $builder->getQuery()->shouldNotReceive('whereIn'); + $builder->shouldNotReceive('get'); + + $result = $builder->findMany(collect(), ['column']); + $this->assertSame($emptyCollection2, $result); + } + + public function testFindOrNewMethodModelFound() + { + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $model->shouldReceive('findOrNew')->once()->andReturn('baz'); + + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('where')->once()->with('foo_table.foo', '=', 'bar'); + $builder->shouldReceive('first')->with(['column'])->andReturn('baz'); + + $expected = $model->findOrNew('bar', ['column']); + $result = $builder->find('bar', ['column']); + $this->assertEquals($expected, $result); + } + + public function testFindOrNewMethodModelNotFound() + { + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $model->shouldReceive('findOrNew')->once()->andReturn(m::mock(Model::class)); + + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('where')->once()->with('foo_table.foo', '=', 'bar'); + $builder->shouldReceive('first')->with(['column'])->andReturn(null); + + $result = $model->findOrNew('bar', ['column']); + $findResult = $builder->find('bar', ['column']); + $this->assertNull($findResult); + $this->assertInstanceOf(Model::class, $result); + } + + public function testFindOrFailMethodThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('where')->once()->with('foo_table.foo', '=', 'bar'); + $builder->shouldReceive('first')->with(['column'])->andReturn(null); + $builder->findOrFail('bar', ['column']); + } + + public function testFindOrFailMethodWithManyThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + + $model = $this->getMockModel(); + $model->shouldReceive('getKey')->andReturn(1); + $model->shouldReceive('getKeyType')->andReturn('int'); + + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('foo_table.foo', [1, 2]); + $builder->shouldReceive('get')->with(['column'])->andReturn(new Collection([$model])); + $builder->findOrFail([1, 2], ['column']); + } + + public function testFindOrFailMethodWithManyUsingCollectionThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + + $model = $this->getMockModel(); + $model->shouldReceive('getKey')->andReturn(1); + $model->shouldReceive('getKeyType')->andReturn('int'); + + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('foo_table.foo', [1, 2]); + $builder->shouldReceive('get')->with(['column'])->andReturn(new Collection([$model])); + $builder->findOrFail(new Collection([1, 2]), ['column']); + } + + public function testFindOrMethod() + { + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->andReturn('int'); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('where')->with('foo_table.foo', '=', 1)->twice(); + $builder->getQuery()->shouldReceive('where')->with('foo_table.foo', '=', 2)->once(); + $builder->shouldReceive('first')->andReturn($model)->once(); + $builder->shouldReceive('first')->with(['column'])->andReturn($model)->once(); + $builder->shouldReceive('first')->andReturn(null)->once(); + + $this->assertSame($model, $builder->findOr(1, fn () => 'callback result')); + $this->assertSame($model, $builder->findOr(1, ['column'], fn () => 'callback result')); + $this->assertSame('callback result', $builder->findOr(2, fn () => 'callback result')); + } + + public function testFindOrMethodWithMany() + { + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $model1 = $this->getMockModel(); + $model2 = $this->getMockModel(); + $model1->shouldReceive('getKeyType')->andReturn('int'); + $model2->shouldReceive('getKeyType')->andReturn('int'); + $builder->setModel($model1); + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->with('foo_table.foo', [1, 2])->twice(); + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3])->once(); + $builder->shouldReceive('get')->andReturn(new Collection([$model1, $model2]))->once(); + $builder->shouldReceive('get')->with(['column'])->andReturn(new Collection([$model1, $model2]))->once(); + $builder->shouldReceive('get')->andReturn(null)->once(); + + $result = $builder->findOr([1, 2], fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame($model1, $result[0]); + $this->assertSame($model2, $result[1]); + + $result = $builder->findOr([1, 2], ['column'], fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame($model1, $result[0]); + $this->assertSame($model2, $result[1]); + + $result = $builder->findOr([1, 2, 3], fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFindOrMethodWithManyUsingCollection() + { + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $model1 = $this->getMockModel(); + $model2 = $this->getMockModel(); + $model1->shouldReceive('getKeyType')->andReturn('int'); + $model2->shouldReceive('getKeyType')->andReturn('int'); + $builder->setModel($model1); + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->with('foo_table.foo', [1, 2])->twice(); + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3])->once(); + $builder->shouldReceive('get')->andReturn(new Collection([$model1, $model2]))->once(); + $builder->shouldReceive('get')->with(['column'])->andReturn(new Collection([$model1, $model2]))->once(); + $builder->shouldReceive('get')->andReturn(null)->once(); + + $result = $builder->findOr(new Collection([1, 2]), fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame($model1, $result[0]); + $this->assertSame($model2, $result[1]); + + $result = $builder->findOr(new Collection([1, 2]), ['column'], fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame($model1, $result[0]); + $this->assertSame($model2, $result[1]); + + $result = $builder->findOr(new Collection([1, 2, 3]), fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFirstOrFailMethodThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $builder->setModel($this->getMockModel()); + $builder->shouldReceive('first')->with(['column'])->andReturn(null); + $builder->firstOrFail(['column']); + } + + public function testFindWithMany() + { + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->andReturn('int'); + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('foo_table.foo', [1, 2]); + $builder->setModel($model); + $builder->shouldReceive('get')->with(['column'])->andReturn('baz'); + + $result = $builder->find([1, 2], ['column']); + $this->assertSame('baz', $result); + } + + public function testFindWithManyUsingCollection() + { + $ids = collect([1, 2]); + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->andReturn('int'); + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('foo_table.foo', [1, 2]); + $builder->setModel($model); + $builder->shouldReceive('get')->with(['column'])->andReturn('baz'); + + $result = $builder->find($ids, ['column']); + $this->assertSame('baz', $result); + } + + public function testFirstMethod() + { + $builder = m::mock(Builder::class.'[get,take]', [$this->getMockQueryBuilder()]); + $builder->shouldReceive('limit')->with(1)->andReturnSelf(); + $builder->shouldReceive('get')->with(['*'])->andReturn(new Collection(['bar'])); + + $result = $builder->first(); + $this->assertSame('bar', $result); + } + + public function testQualifyColumn() + { + $builder = new Builder(m::mock(BaseBuilder::class)); + $builder->shouldReceive('from')->with('foo_table'); + + $builder->setModel(new EloquentBuilderTestStubStringPrimaryKey); + + $this->assertSame('foo_table.column', $builder->qualifyColumn('column')); + } + + public function testQualifyColumns() + { + $builder = new Builder(m::mock(BaseBuilder::class)); + $builder->shouldReceive('from')->with('foo_table'); + + $builder->setModel(new EloquentBuilderTestStubStringPrimaryKey); + + $this->assertEquals(['foo_table.column', 'foo_table.name'], $builder->qualifyColumns(['column', 'name'])); + } + + public function testGetMethodLoadsModelsAndHydratesEagerRelations() + { + $builder = m::mock(Builder::class.'[getModels,eagerLoadRelations]', [$this->getMockQueryBuilder()]); + $builder->shouldReceive('applyScopes')->andReturnSelf(); + $builder->shouldReceive('getModels')->with(['foo'])->andReturn(['bar']); + $builder->shouldReceive('eagerLoadRelations')->with(['bar'])->andReturn(['bar', 'baz']); + $builder->setModel($this->getMockModel()); + $builder->getModel()->shouldReceive('newCollection')->with(['bar', 'baz'])->andReturn(new Collection(['bar', 'baz'])); + + $results = $builder->get(['foo']); + $this->assertEquals(['bar', 'baz'], $results->all()); + } + + public function testGetMethodDoesntHydrateEagerRelationsWhenNoResultsAreReturned() + { + $builder = m::mock(Builder::class.'[getModels,eagerLoadRelations]', [$this->getMockQueryBuilder()]); + $builder->shouldReceive('applyScopes')->andReturnSelf(); + $builder->shouldReceive('getModels')->with(['foo'])->andReturn([]); + $builder->shouldReceive('eagerLoadRelations')->never(); + $builder->setModel($this->getMockModel()); + $builder->getModel()->shouldReceive('newCollection')->with([])->andReturn(new Collection([])); + + $results = $builder->get(['foo']); + $this->assertEquals([], $results->all()); + } + + public function testValueMethodWithModelFound() + { + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $mockModel = new stdClass; + $mockModel->name = 'foo'; + $builder->shouldReceive('first')->with(['name'])->andReturn($mockModel); + + $this->assertSame('foo', $builder->value('name')); + } + + public function testValueMethodWithModelNotFound() + { + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $builder->shouldReceive('first')->with(['name'])->andReturn(null); + + $this->assertNull($builder->value('name')); + } + + public function testValueOrFailMethodWithModelFound() + { + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $mockModel = new stdClass; + $mockModel->name = 'foo'; + $builder->shouldReceive('first')->with(['name'])->andReturn($mockModel); + + $this->assertSame('foo', $builder->valueOrFail('name')); + } + + public function testValueOrFailMethodWithModelNotFoundThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + + $builder = m::mock(Builder::class.'[first]', [$this->getMockQueryBuilder()]); + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('where')->once()->with('foo_table.foo', '=', 'bar'); + $builder->shouldReceive('first')->with(['column'])->andReturn(null); + $builder->whereKey('bar')->valueOrFail('column'); + } + + public function testChunkWithLastChunkComplete() + { + $builder = m::mock(Builder::class.'[getOffset,getLimit,offset,limit,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection(['foo1', 'foo2']); + $chunk2 = new Collection(['foo3', 'foo4']); + $chunk3 = new Collection([]); + + $builder->shouldReceive('getOffset')->once()->andReturn(null); + $builder->shouldReceive('getLimit')->once()->andReturn(null); + $builder->shouldReceive('offset')->once()->with(0)->andReturnSelf(); + $builder->shouldReceive('offset')->once()->with(2)->andReturnSelf(); + $builder->shouldReceive('offset')->once()->with(4)->andReturnSelf(); + $builder->shouldReceive('limit')->times(3)->with(2)->andReturnSelf(); + $builder->shouldReceive('get')->times(3)->andReturn($chunk1, $chunk2, $chunk3); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk3); + + $builder->chunk(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }); + } + + public function testChunkWithLastChunkPartial() + { + $builder = m::mock(Builder::class.'[getOffset,getLimit,offset,limit,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection(['foo1', 'foo2']); + $chunk2 = new Collection(['foo3']); + $builder->shouldReceive('getOffset')->once()->andReturn(null); + $builder->shouldReceive('getLimit')->once()->andReturn(null); + $builder->shouldReceive('offset')->once()->with(0)->andReturnSelf(); + $builder->shouldReceive('offset')->once()->with(2)->andReturnSelf(); + $builder->shouldReceive('limit')->twice()->with(2)->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2); + + $builder->chunk(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }); + } + + public function testChunkCanBeStoppedByReturningFalse() + { + $builder = m::mock(Builder::class.'[getOffset,getLimit,offset,limit,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection(['foo1', 'foo2']); + $chunk2 = new Collection(['foo3']); + + $builder->shouldReceive('getOffset')->once()->andReturn(null); + $builder->shouldReceive('getLimit')->once()->andReturn(null); + $builder->shouldReceive('offset')->once()->with(0)->andReturnSelf(); + $builder->shouldReceive('limit')->once()->with(2)->andReturnSelf(); + $builder->shouldReceive('get')->times(1)->andReturn($chunk1); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk2); + + $builder->chunk(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + + return false; + }); + } + + public function testChunkWithCountZero() + { + $builder = m::mock(Builder::class.'[getOffset,getLimit,offset,limit,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('getOffset')->once()->andReturn(null); + $builder->shouldReceive('getLimit')->once()->andReturn(null); + $builder->shouldReceive('offset')->never(); + $builder->shouldReceive('limit')->never(); + $builder->shouldReceive('get')->never(); + + $builder->chunk(0, function () { + $this->fail('Should not be called.'); + }); + } + + public function testChunkPaginatesUsingIdWithLastChunkComplete() + { + $builder = m::mock(Builder::class.'[getOffset,getLimit,forPageAfterId,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $chunk2 = new Collection([(object) ['someIdField' => 10], (object) ['someIdField' => 11]]); + $chunk3 = new Collection([]); + $builder->shouldReceive('getOffset')->andReturnNull(); + $builder->shouldReceive('getLimit')->andReturnNull(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 11, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(3)->andReturn($chunk1, $chunk2, $chunk3); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk3); + + $builder->chunkById(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }, 'someIdField'); + } + + public function testChunkPaginatesUsingIdWithLastChunkPartial() + { + $builder = m::mock(Builder::class.'[getOffset,getLimit,forPageAfterId,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $chunk2 = new Collection([(object) ['someIdField' => 10]]); + $builder->shouldReceive('getOffset')->andReturnNull(); + $builder->shouldReceive('getLimit')->andReturnNull(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2); + + $builder->chunkById(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }, 'someIdField'); + } + + public function testChunkPaginatesUsingIdWithCountZero() + { + $builder = m::mock(Builder::class.'[getOffset,getLimit,forPageAfterId,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('getOffset')->andReturnNull(); + $builder->shouldReceive('getLimit')->andReturnNull(); + $builder->shouldReceive('forPageAfterId')->never(); + $builder->shouldReceive('get')->never(); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->never(); + + $builder->chunkById(0, function () { + $this->fail('Should never be called.'); + }, 'someIdField'); + } + + public function testLazyWithLastChunkComplete() + { + $builder = m::mock(Builder::class.'[forPage,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('forPage')->once()->with(1, 2)->andReturnSelf(); + $builder->shouldReceive('forPage')->once()->with(2, 2)->andReturnSelf(); + $builder->shouldReceive('forPage')->once()->with(3, 2)->andReturnSelf(); + $builder->shouldReceive('get')->times(3)->andReturn( + new Collection(['foo1', 'foo2']), + new Collection(['foo3', 'foo4']), + new Collection([]) + ); + + $this->assertEquals( + ['foo1', 'foo2', 'foo3', 'foo4'], + $builder->lazy(2)->all() + ); + } + + public function testLazyWithLastChunkPartial() + { + $builder = m::mock(Builder::class.'[forPage,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('forPage')->once()->with(1, 2)->andReturnSelf(); + $builder->shouldReceive('forPage')->once()->with(2, 2)->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn( + new Collection(['foo1', 'foo2']), + new Collection(['foo3']) + ); + + $this->assertEquals( + ['foo1', 'foo2', 'foo3'], + $builder->lazy(2)->all() + ); + } + + public function testLazyIsLazy() + { + $builder = m::mock(Builder::class.'[forPage,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('forPage')->once()->with(1, 2)->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn(new Collection(['foo1', 'foo2'])); + + $this->assertEquals(['foo1', 'foo2'], $builder->lazy(2)->take(2)->all()); + } + + public function testLazyByIdWithLastChunkComplete() + { + $builder = m::mock(Builder::class.'[forPageAfterId,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $chunk2 = new Collection([(object) ['someIdField' => 10], (object) ['someIdField' => 11]]); + $chunk3 = new Collection([]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 11, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(3)->andReturn($chunk1, $chunk2, $chunk3); + + $this->assertEquals( + [ + (object) ['someIdField' => 1], + (object) ['someIdField' => 2], + (object) ['someIdField' => 10], + (object) ['someIdField' => 11], + ], + $builder->lazyById(2, 'someIdField')->all() + ); + } + + public function testLazyByIdWithLastChunkPartial() + { + $builder = m::mock(Builder::class.'[forPageAfterId,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $chunk2 = new Collection([(object) ['someIdField' => 10]]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2); + + $this->assertEquals( + [ + (object) ['someIdField' => 1], + (object) ['someIdField' => 2], + (object) ['someIdField' => 10], + ], + $builder->lazyById(2, 'someIdField')->all() + ); + } + + public function testLazyByIdIsLazy() + { + $builder = m::mock(Builder::class.'[forPageAfterId,get]', [$this->getMockQueryBuilder()]); + $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = new Collection([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn($chunk1); + + $this->assertEquals( + [ + (object) ['someIdField' => 1], + (object) ['someIdField' => 2], + ], + $builder->lazyById(2, 'someIdField')->take(2)->all() + ); + } + + public function testPluckReturnsTheMutatedAttributesOfAModel() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('pluck')->with('name', '')->andReturn(new BaseCollection(['bar', 'baz'])); + $model = m::mock(EloquentBuilderTestPluckStub::class)->makePartial(); + $model->shouldReceive('getKeyName')->andReturn('foo'); + $model->shouldReceive('getTable')->andReturn('foo_table'); + $model->shouldReceive('getQualifiedKeyName')->andReturn('foo_table.foo'); + $model->shouldReceive('hasAnyGetMutator')->with('name')->andReturn(true); + // Return fresh partial mocks with getAttribute configured to return the expected value + $model->shouldReceive('newFromBuilder')->andReturnUsing(function ($attributes) { + $stub = m::mock(EloquentBuilderTestPluckStub::class)->makePartial(); + $value = $attributes[array_key_first($attributes)]; + $stub->shouldReceive('getAttribute')->andReturn('foo_' . $value); + return $stub; + }); + $builder->setModel($model); + + $this->assertEquals(['foo_bar', 'foo_baz'], $builder->pluck('name')->all()); + } + + public function testPluckReturnsTheCastedAttributesOfAModel() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('pluck')->with('name', '')->andReturn(new BaseCollection(['bar', 'baz'])); + $model = m::mock(EloquentBuilderTestPluckStub::class)->makePartial(); + $model->shouldReceive('getKeyName')->andReturn('foo'); + $model->shouldReceive('getTable')->andReturn('foo_table'); + $model->shouldReceive('getQualifiedKeyName')->andReturn('foo_table.foo'); + $model->shouldReceive('hasAnyGetMutator')->with('name')->andReturn(false); + $model->shouldReceive('hasCast')->with('name')->andReturn(true); + $model->shouldReceive('newFromBuilder')->andReturnUsing(function ($attributes) { + $stub = m::mock(EloquentBuilderTestPluckStub::class)->makePartial(); + $value = $attributes[array_key_first($attributes)]; + $stub->shouldReceive('getAttribute')->andReturn('foo_' . $value); + return $stub; + }); + $builder->setModel($model); + + $this->assertEquals(['foo_bar', 'foo_baz'], $builder->pluck('name')->all()); + } + + public function testPluckReturnsTheDateAttributesOfAModel() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('pluck')->with('created_at', '')->andReturn(new BaseCollection(['2010-01-01 00:00:00', '2011-01-01 00:00:00'])); + $model = m::mock(EloquentBuilderTestPluckDatesStub::class)->makePartial(); + $model->shouldReceive('getKeyName')->andReturn('foo'); + $model->shouldReceive('getTable')->andReturn('foo_table'); + $model->shouldReceive('getQualifiedKeyName')->andReturn('foo_table.foo'); + $model->shouldReceive('hasAnyGetMutator')->with('created_at')->andReturn(false); + $model->shouldReceive('hasCast')->with('created_at')->andReturn(false); + $model->shouldReceive('getDates')->andReturn(['created_at']); + $model->shouldReceive('newFromBuilder')->andReturnUsing(function ($attributes) { + $stub = m::mock(EloquentBuilderTestPluckDatesStub::class)->makePartial(); + $value = $attributes[array_key_first($attributes)]; + $stub->shouldReceive('getAttribute')->andReturn('date_' . $value); + return $stub; + }); + $builder->setModel($model); + + $this->assertEquals(['date_2010-01-01 00:00:00', 'date_2011-01-01 00:00:00'], $builder->pluck('created_at')->all()); + } + + public function testQualifiedPluckReturnsTheMutatedAttributesOfAModel() + { + $model = m::mock(EloquentBuilderTestPluckStub::class)->makePartial(); + $model->shouldReceive('getKeyName')->andReturn('foo'); + $model->shouldReceive('getTable')->andReturn('foo_table'); + $model->shouldReceive('getQualifiedKeyName')->andReturn('foo_table.foo'); + $model->shouldReceive('qualifyColumn')->with('name')->andReturn('foo_table.name'); + $model->shouldReceive('hasAnyGetMutator')->with('name')->andReturn(true); + $model->shouldReceive('newFromBuilder')->andReturnUsing(function ($attributes) { + $stub = m::mock(EloquentBuilderTestPluckStub::class)->makePartial(); + $value = $attributes[array_key_first($attributes)]; + $stub->shouldReceive('getAttribute')->andReturn('foo_' . $value); + return $stub; + }); + + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('pluck')->with($model->qualifyColumn('name'), '')->andReturn(new BaseCollection(['bar', 'baz'])); + $builder->setModel($model); + + $this->assertEquals(['foo_bar', 'foo_baz'], $builder->pluck($model->qualifyColumn('name'))->all()); + } + + public function testQualifiedPluckReturnsTheCastedAttributesOfAModel() + { + $model = m::mock(EloquentBuilderTestPluckStub::class)->makePartial(); + $model->shouldReceive('getKeyName')->andReturn('foo'); + $model->shouldReceive('getTable')->andReturn('foo_table'); + $model->shouldReceive('getQualifiedKeyName')->andReturn('foo_table.foo'); + $model->shouldReceive('qualifyColumn')->with('name')->andReturn('foo_table.name'); + $model->shouldReceive('hasAnyGetMutator')->with('name')->andReturn(false); + $model->shouldReceive('hasCast')->with('name')->andReturn(true); + $model->shouldReceive('newFromBuilder')->andReturnUsing(function ($attributes) { + $stub = m::mock(EloquentBuilderTestPluckStub::class)->makePartial(); + $value = $attributes[array_key_first($attributes)]; + $stub->shouldReceive('getAttribute')->andReturn('foo_' . $value); + return $stub; + }); + + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('pluck')->with($model->qualifyColumn('name'), '')->andReturn(new BaseCollection(['bar', 'baz'])); + $builder->setModel($model); + + $this->assertEquals(['foo_bar', 'foo_baz'], $builder->pluck($model->qualifyColumn('name'))->all()); + } + + public function testQualifiedPluckReturnsTheDateAttributesOfAModel() + { + $model = m::mock(EloquentBuilderTestPluckDatesStub::class)->makePartial(); + $model->shouldReceive('getKeyName')->andReturn('foo'); + $model->shouldReceive('getTable')->andReturn('foo_table'); + $model->shouldReceive('getQualifiedKeyName')->andReturn('foo_table.foo'); + $model->shouldReceive('qualifyColumn')->with('created_at')->andReturn('foo_table.created_at'); + $model->shouldReceive('hasAnyGetMutator')->with('created_at')->andReturn(false); + $model->shouldReceive('hasCast')->with('created_at')->andReturn(false); + $model->shouldReceive('getDates')->andReturn(['created_at']); + $model->shouldReceive('newFromBuilder')->andReturnUsing(function ($attributes) { + $stub = m::mock(EloquentBuilderTestPluckDatesStub::class)->makePartial(); + $value = $attributes[array_key_first($attributes)]; + $stub->shouldReceive('getAttribute')->andReturn('date_' . $value); + return $stub; + }); + + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('pluck')->with($model->qualifyColumn('created_at'), '')->andReturn(new BaseCollection(['2010-01-01 00:00:00', '2011-01-01 00:00:00'])); + $builder->setModel($model); + + $this->assertEquals(['date_2010-01-01 00:00:00', 'date_2011-01-01 00:00:00'], $builder->pluck($model->qualifyColumn('created_at'))->all()); + } + + public function testPluckWithoutModelGetterJustReturnsTheAttributesFoundInDatabase() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('pluck')->with('name', '')->andReturn(new BaseCollection(['bar', 'baz'])); + $builder->setModel($this->getMockModel()); + $builder->getModel()->shouldReceive('hasAnyGetMutator')->with('name')->andReturn(false); + $builder->getModel()->shouldReceive('hasCast')->with('name')->andReturn(false); + $builder->getModel()->shouldReceive('getDates')->andReturn(['created_at']); + + $this->assertEquals(['bar', 'baz'], $builder->pluck('name')->all()); + } + + public function testLocalMacrosAreCalledOnBuilder() + { + unset($_SERVER['__test.builder']); + $builder = new Builder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + $builder->macro('fooBar', function ($builder) { + $_SERVER['__test.builder'] = $builder; + + return $builder; + }); + $result = $builder->fooBar(); + + $this->assertTrue($builder->hasMacro('fooBar')); + $this->assertEquals($builder, $result); + $this->assertEquals($builder, $_SERVER['__test.builder']); + unset($_SERVER['__test.builder']); + } + + public function testGlobalMacrosAreCalledOnBuilder() + { + Builder::macro('foo', function ($bar) { + return $bar; + }); + + Builder::macro('bam', function () { + return $this->getQuery(); + }); + + $builder = $this->getBuilder(); + + $this->assertTrue(Builder::hasGlobalMacro('foo')); + $this->assertSame('bar', $builder->foo('bar')); + $this->assertEquals($builder->bam(), $builder->getQuery()); + } + + public function testMissingStaticMacrosThrowsProperException() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Call to undefined method Hypervel\Database\Eloquent\Builder::missingMacro()'); + + Builder::missingMacro(); + } + + public function testGetModelsProperlyHydratesModels() + { + $builder = m::mock(Builder::class.'[get]', [$this->getMockQueryBuilder()]); + $records[] = ['name' => 'taylor', 'age' => 26]; + $records[] = ['name' => 'dayle', 'age' => 28]; + $builder->getQuery()->shouldReceive('get')->once()->with(['foo'])->andReturn(new BaseCollection($records)); + $model = m::mock(Model::class.'[getTable,hydrate]'); + $model->shouldReceive('getTable')->once()->andReturn('foo_table'); + $builder->setModel($model); + $model->shouldReceive('hydrate')->once()->with($records)->andReturn(new Collection(['hydrated'])); + $models = $builder->getModels(['foo']); + + $this->assertEquals(['hydrated'], $models); + } + + public function testEagerLoadRelationsLoadTopLevelRelationships() + { + $builder = m::mock(Builder::class.'[eagerLoadRelation]', [$this->getMockQueryBuilder()]); + $nop1 = function () { + // + }; + $nop2 = function () { + // + }; + $builder->setEagerLoads(['foo' => $nop1, 'foo.bar' => $nop2]); + $builder->shouldAllowMockingProtectedMethods()->shouldReceive('eagerLoadRelation')->with(['models'], 'foo', $nop1)->andReturn(['foo']); + + $results = $builder->eagerLoadRelations(['models']); + $this->assertEquals(['foo'], $results); + } + + public function testEagerLoadRelationsCanBeFlushed() + { + $builder = m::mock(Builder::class.'[eagerLoadRelation]', [$this->getMockQueryBuilder()]); + + $builder->setEagerLoads(['foo']); + + $this->assertSame(['foo'], $builder->getEagerLoads()); + + $builder->withoutEagerLoads(); + + $this->assertEmpty($builder->getEagerLoads()); + } + + public function testRelationshipEagerLoadProcess() + { + $builder = m::mock(Builder::class.'[getRelation]', [$this->getMockQueryBuilder()]); + $builder->setEagerLoads(['orders' => function ($query) { + $_SERVER['__eloquent.constrain'] = $query; + }]); + $relation = m::mock(stdClass::class); + $relation->shouldReceive('addEagerConstraints')->once()->with(['models']); + $relation->shouldReceive('initRelation')->once()->with(['models'], 'orders')->andReturn(['models']); + $relation->shouldReceive('getEager')->once()->andReturn(['results']); + $relation->shouldReceive('match')->once()->with(['models'], ['results'], 'orders')->andReturn(['models.matched']); + $builder->shouldReceive('getRelation')->once()->with('orders')->andReturn($relation); + $results = $builder->eagerLoadRelations(['models']); + + $this->assertEquals(['models.matched'], $results); + $this->assertEquals($relation, $_SERVER['__eloquent.constrain']); + unset($_SERVER['__eloquent.constrain']); + } + + public function testRelationshipEagerLoadProcessForImplicitlyEmpty() + { + $queryBuilder = $this->getMockQueryBuilder(); + $builder = m::mock(Builder::class.'[getRelation]', [$queryBuilder]); + $builder->setEagerLoads(['parentFoo' => function ($query) { + $_SERVER['__eloquent.constrain'] = $query; + }]); + $model = new EloquentBuilderTestModelSelfRelatedStub; + $this->mockConnectionForModel($model, 'SQLite'); + + $models = [ + new EloquentBuilderTestModelSelfRelatedStub, + new EloquentBuilderTestModelSelfRelatedStub, + ]; + $relation = m::mock($model->parentFoo()); + + $builder->shouldReceive('getRelation')->once()->with('parentFoo')->andReturn($relation); + + $results = $builder->eagerLoadRelations($models); + + unset($_SERVER['__eloquent.constrain']); + } + + public function testGetRelationProperlySetsNestedRelationships() + { + $builder = $this->getBuilder(); + $builder->setModel($this->getMockModel()); + $builder->getModel()->shouldReceive('newInstance->orders')->once()->andReturn($relation = m::mock(stdClass::class)); + $relationQuery = m::mock(stdClass::class); + $relation->shouldReceive('getQuery')->andReturn($relationQuery); + $relationQuery->shouldReceive('with')->once()->with(['lines' => null, 'lines.details' => null]); + $builder->setEagerLoads(['orders' => null, 'orders.lines' => null, 'orders.lines.details' => null]); + + $builder->getRelation('orders'); + } + + public function testGetRelationProperlySetsNestedRelationshipsWithSimilarNames() + { + $builder = $this->getBuilder(); + $builder->setModel($this->getMockModel()); + $builder->getModel()->shouldReceive('newInstance->orders')->once()->andReturn($relation = m::mock(stdClass::class)); + $builder->getModel()->shouldReceive('newInstance->ordersGroups')->once()->andReturn($groupsRelation = m::mock(stdClass::class)); + + $relationQuery = m::mock(stdClass::class); + $relation->shouldReceive('getQuery')->andReturn($relationQuery); + + $groupRelationQuery = m::mock(stdClass::class); + $groupsRelation->shouldReceive('getQuery')->andReturn($groupRelationQuery); + $groupRelationQuery->shouldReceive('with')->once()->with(['lines' => null, 'lines.details' => null]); + + $builder->setEagerLoads(['orders' => null, 'ordersGroups' => null, 'ordersGroups.lines' => null, 'ordersGroups.lines.details' => null]); + + $builder->getRelation('orders'); + $builder->getRelation('ordersGroups'); + } + + public function testGetRelationThrowsException() + { + $this->expectException(RelationNotFoundException::class); + + $builder = $this->getBuilder(); + $builder->setModel($this->getMockModel()); + + $builder->getRelation('invalid'); + } + + public function testEagerLoadParsingSetsProperRelationships() + { + $builder = $this->getBuilder(); + $builder->with(['orders', 'orders.lines']); + $eagers = $builder->getEagerLoads(); + + $this->assertEquals(['orders', 'orders.lines'], array_keys($eagers)); + $this->assertInstanceOf(Closure::class, $eagers['orders']); + $this->assertInstanceOf(Closure::class, $eagers['orders.lines']); + + $builder = $this->getBuilder(); + $builder->with('orders', 'orders.lines'); + $eagers = $builder->getEagerLoads(); + + $this->assertEquals(['orders', 'orders.lines'], array_keys($eagers)); + $this->assertInstanceOf(Closure::class, $eagers['orders']); + $this->assertInstanceOf(Closure::class, $eagers['orders.lines']); + + $builder = $this->getBuilder(); + $builder->with(['orders.lines']); + $eagers = $builder->getEagerLoads(); + + $this->assertEquals(['orders', 'orders.lines'], array_keys($eagers)); + $this->assertInstanceOf(Closure::class, $eagers['orders']); + $this->assertInstanceOf(Closure::class, $eagers['orders.lines']); + + $builder = $this->getBuilder(); + $builder->with(['orders' => function () { + return 'foo'; + }]); + $eagers = $builder->getEagerLoads(); + + $this->assertSame('foo', $eagers['orders']($this->getBuilder())); + + $builder = $this->getBuilder(); + $builder->with(['orders.lines' => function () { + return 'foo'; + }]); + $eagers = $builder->getEagerLoads(); + + $this->assertInstanceOf(Closure::class, $eagers['orders']); + $this->assertNull($eagers['orders']()); + $this->assertSame('foo', $eagers['orders.lines']($this->getBuilder())); + + $builder = $this->getBuilder(); + $builder->with('orders.lines', function () { + return 'foo'; + }); + $eagers = $builder->getEagerLoads(); + + $this->assertInstanceOf(Closure::class, $eagers['orders']); + $this->assertNull($eagers['orders']()); + $this->assertSame('foo', $eagers['orders.lines']($this->getBuilder())); + } + + public function testQueryPassThru() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('foobar')->once()->andReturn('foo'); + + $this->assertInstanceOf(Builder::class, $builder->foobar()); + + // Hypervel has strict return types on insert methods, so we use correct types + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('insert')->once()->with(['bar'])->andReturn(true); + + $this->assertTrue($builder->insert(['bar'])); + + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('insertOrIgnore')->once()->with(['bar'])->andReturn(1); + + $this->assertSame(1, $builder->insertOrIgnore(['bar'])); + + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('insertOrIgnoreUsing')->once()->with(['bar'], 'baz')->andReturn(1); + + $this->assertSame(1, $builder->insertOrIgnoreUsing(['bar'], 'baz')); + + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('insertGetId')->once()->with(['bar'])->andReturn(123); + + $this->assertSame(123, $builder->insertGetId(['bar'])); + + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('insertUsing')->once()->with(['bar'], 'baz')->andReturn(1); + + $this->assertSame(1, $builder->insertUsing(['bar'], 'baz')); + + $builder = $this->getBuilder(); + $expression = new Expression('foo'); + $builder->getQuery()->shouldReceive('raw')->once()->with('bar')->andReturn($expression); + + $this->assertSame($expression, $builder->raw('bar')); + } + + public function testQueryScopes() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('from'); + $builder->getQuery()->shouldReceive('where')->once()->with('foo', 'bar'); + $builder->setModel($model = new EloquentBuilderTestScopeStub); + $result = $builder->approved(); + + $this->assertEquals($builder, $result); + } + + public function testQueryDynamicScopes() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('from'); + $builder->getQuery()->shouldReceive('where')->once()->with('bar', 'foo'); + $builder->setModel($model = new EloquentBuilderTestDynamicScopeStub); + $result = $builder->dynamic('bar', 'foo'); + + $this->assertEquals($builder, $result); + } + + public function testQueryDynamicScopesNamed() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('from'); + $builder->getQuery()->shouldReceive('where')->once()->with('foo', 'foo'); + $builder->setModel($model = new EloquentBuilderTestDynamicScopeStub); + $result = $builder->dynamic(bar: 'foo'); + + $this->assertEquals($builder, $result); + } + + public function testNestedWhere() + { + $nestedQuery = m::mock(Builder::class); + $nestedRawQuery = $this->getMockQueryBuilder(); + $nestedQuery->shouldReceive('getQuery')->once()->andReturn($nestedRawQuery); + $nestedQuery->shouldReceive('getEagerLoads')->once()->andReturn([]); + $model = $this->getMockModel()->makePartial(); + $model->shouldReceive('newQueryWithoutRelationships')->once()->andReturn($nestedQuery); + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('from'); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('addNestedWhereQuery')->once()->with($nestedRawQuery, 'and'); + $nestedQuery->shouldReceive('foo')->once(); + + $result = $builder->where(function ($query) { + $query->foo(); + }); + $this->assertEquals($builder, $result); + } + + public function testRealNestedWhereWithScopes() + { + $model = new EloquentBuilderTestNestedStub; + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->where('foo', '=', 'bar')->where(function ($query) { + $query->where('baz', '>', 9000); + }); + $this->assertSame('select * from "table" where "foo" = ? and ("baz" > ?) and "table"."deleted_at" is null', $query->toSql()); + $this->assertEquals(['bar', 9000], $query->getBindings()); + } + + public function testRealNestedWhereWithScopesMacro() + { + $model = new EloquentBuilderTestNestedStub; + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->where('foo', '=', 'bar')->where(function ($query) { + $query->where('baz', '>', 9000)->onlyTrashed(); + })->withTrashed(); + $this->assertSame('select * from "table" where "foo" = ? and ("baz" > ? and "table"."deleted_at" is not null)', $query->toSql()); + $this->assertEquals(['bar', 9000], $query->getBindings()); + } + + public function testRealNestedWhereWithMultipleScopesAndOneDeadScope() + { + $model = new EloquentBuilderTestNestedStub; + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->empty()->where('foo', '=', 'bar')->empty()->where(function ($query) { + $query->empty()->where('baz', '>', 9000); + }); + $this->assertSame('select * from "table" where "foo" = ? and ("baz" > ?) and "table"."deleted_at" is null', $query->toSql()); + $this->assertEquals(['bar', 9000], $query->getBindings()); + } + + public function testSimpleWhereNot() + { + $model = new EloquentBuilderTestStub(); + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->whereNot('name', 'foo')->whereNot('name', '<>', 'bar'); + $this->assertEquals('select * from "table" where not "name" = ? and not "name" <> ?', $query->toSql()); + $this->assertEquals(['foo', 'bar'], $query->getBindings()); + } + + public function testWhereNot() + { + $nestedQuery = m::mock(Builder::class); + $nestedRawQuery = $this->getMockQueryBuilder(); + $nestedQuery->shouldReceive('getQuery')->once()->andReturn($nestedRawQuery); + $nestedQuery->shouldReceive('getEagerLoads')->once()->andReturn([]); + $model = $this->getMockModel()->makePartial(); + $model->shouldReceive('newQueryWithoutRelationships')->once()->andReturn($nestedQuery); + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('from'); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('addNestedWhereQuery')->once()->with($nestedRawQuery, 'and not'); + $nestedQuery->shouldReceive('foo')->once(); + + $result = $builder->whereNot(function ($query) { + $query->foo(); + }); + $this->assertEquals($builder, $result); + } + + public function testSimpleOrWhereNot() + { + $model = new EloquentBuilderTestStub(); + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->orWhereNot('name', 'foo')->orWhereNot('name', '<>', 'bar'); + $this->assertEquals('select * from "table" where not "name" = ? or not "name" <> ?', $query->toSql()); + $this->assertEquals(['foo', 'bar'], $query->getBindings()); + } + + public function testOrWhereNot() + { + $nestedQuery = m::mock(Builder::class); + $nestedRawQuery = $this->getMockQueryBuilder(); + $nestedQuery->shouldReceive('getQuery')->once()->andReturn($nestedRawQuery); + $nestedQuery->shouldReceive('getEagerLoads')->once()->andReturn([]); + $model = $this->getMockModel()->makePartial(); + $model->shouldReceive('newQueryWithoutRelationships')->once()->andReturn($nestedQuery); + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('from'); + $builder->setModel($model); + $builder->getQuery()->shouldReceive('addNestedWhereQuery')->once()->with($nestedRawQuery, 'or not'); + $nestedQuery->shouldReceive('foo')->once(); + + $result = $builder->orWhereNot(function ($query) { + $query->foo(); + }); + $this->assertEquals($builder, $result); + } + + public function testRealQueryHigherOrderOrWhereScopes() + { + $model = new EloquentBuilderTestHigherOrderWhereScopeStub; + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->one()->orWhere->two(); + $this->assertSame('select * from "table" where "one" = ? or ("two" = ?)', $query->toSql()); + } + + public function testRealQueryChainedHigherOrderOrWhereScopes() + { + $model = new EloquentBuilderTestHigherOrderWhereScopeStub; + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->one()->orWhere->two()->orWhere->three(); + $this->assertSame('select * from "table" where "one" = ? or ("two" = ?) or ("three" = ?)', $query->toSql()); + } + + public function testRealQueryHigherOrderWhereNotScopes() + { + $model = new EloquentBuilderTestHigherOrderWhereScopeStub; + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->one()->whereNot->two(); + $this->assertSame('select * from "table" where "one" = ? and not ("two" = ?)', $query->toSql()); + } + + public function testRealQueryChainedHigherOrderWhereNotScopes() + { + $model = new EloquentBuilderTestHigherOrderWhereScopeStub; + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->one()->whereNot->two()->whereNot->three(); + $this->assertSame('select * from "table" where "one" = ? and not ("two" = ?) and not ("three" = ?)', $query->toSql()); + } + + public function testRealQueryHigherOrderOrWhereNotScopes() + { + $model = new EloquentBuilderTestHigherOrderWhereScopeStub; + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->one()->orWhereNot->two(); + $this->assertSame('select * from "table" where "one" = ? or not ("two" = ?)', $query->toSql()); + } + + public function testRealQueryChainedHigherOrderOrWhereNotScopes() + { + $model = new EloquentBuilderTestHigherOrderWhereScopeStub; + $this->mockConnectionForModel($model, 'SQLite'); + $query = $model->newQuery()->one()->orWhereNot->two()->orWhereNot->three(); + $this->assertSame('select * from "table" where "one" = ? or not ("two" = ?) or not ("three" = ?)', $query->toSql()); + } + + public function testSimpleWhere() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('where')->once()->with('foo', '=', 'bar'); + $result = $builder->where('foo', '=', 'bar'); + $this->assertEquals($result, $builder); + } + + public function testPostgresOperatorsWhere() + { + $builder = $this->getBuilder(); + $builder->getQuery()->shouldReceive('where')->once()->with('foo', '@>', 'bar'); + $result = $builder->where('foo', '@>', 'bar'); + $this->assertEquals($result, $builder); + } + + public function testWhereBelongsTo() + { + $related = new EloquentBuilderTestWhereBelongsToStub([ + 'id' => 1, + 'parent_id' => 2, + ]); + + $parent = new EloquentBuilderTestWhereBelongsToStub([ + 'id' => 2, + 'parent_id' => 1, + ]); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('eloquent_builder_test_where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('whereIn')->once()->with('eloquent_builder_test_where_belongs_to_stubs.parent_id', [2], 'and'); + + $result = $builder->whereBelongsTo($parent); + $this->assertEquals($result, $builder); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('eloquent_builder_test_where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('whereIn')->once()->with('eloquent_builder_test_where_belongs_to_stubs.parent_id', [2], 'and'); + + $result = $builder->whereBelongsTo($parent, 'parent'); + $this->assertEquals($result, $builder); + + $parents = new Collection([new EloquentBuilderTestWhereBelongsToStub([ + 'id' => 2, + 'parent_id' => 1, + ]), new EloquentBuilderTestWhereBelongsToStub([ + 'id' => 3, + 'parent_id' => 1, + ])]); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('eloquent_builder_test_where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('whereIn')->once()->with('eloquent_builder_test_where_belongs_to_stubs.parent_id', [2, 3], 'and'); + + $result = $builder->whereBelongsTo($parents); + $this->assertEquals($result, $builder); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('eloquent_builder_test_where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('whereIn')->once()->with('eloquent_builder_test_where_belongs_to_stubs.parent_id', [2, 3], 'and'); + + $result = $builder->whereBelongsTo($parents, 'parent'); + $this->assertEquals($result, $builder); + } + + public function testWhereAttachedTo() + { + $related = new EloquentBuilderTestModelFarRelatedStub; + $related->id = 49; + $related->name = 'test'; + + $builder = EloquentBuilderTestModelParentStub::whereAttachedTo($related, 'roles'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where exists (select * from "eloquent_builder_test_model_far_related_stubs" inner join "user_role" on "eloquent_builder_test_model_far_related_stubs"."id" = "user_role"."related_id" where "eloquent_builder_test_model_parent_stubs"."id" = "user_role"."self_id" and "eloquent_builder_test_model_far_related_stubs"."id" in (49))', $builder->toSql()); + } + + public function testWhereAttachedToCollection() + { + $model1 = new EloquentBuilderTestModelParentStub; + $model1->id = 3; + $model1->name = 'test3'; + + $model2 = new EloquentBuilderTestModelParentStub; + $model2->id = 4; + $model2->name = 'test4'; + + $builder = EloquentBuilderTestModelFarRelatedStub::whereAttachedTo(new Collection([$model1, $model2]), 'roles'); + + $this->assertSame('select * from "eloquent_builder_test_model_far_related_stubs" where exists (select * from "eloquent_builder_test_model_parent_stubs" inner join "user_role" on "eloquent_builder_test_model_parent_stubs"."id" = "user_role"."self_id" where "eloquent_builder_test_model_far_related_stubs"."id" = "user_role"."related_id" and "eloquent_builder_test_model_parent_stubs"."id" in (3, 4))', $builder->toSql()); + } + + public function testDeleteOverride() + { + $builder = $this->getBuilder(); + $builder->onDelete(function ($builder) { + return ['foo' => $builder]; + }); + $this->assertEquals(['foo' => $builder], $builder->delete()); + } + + public function testWithCount() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withCount('foo'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_count" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountAndSelect() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->select('id')->withCount('foo'); + + $this->assertSame('select "id", (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_count" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountSecondRelationWithClosure() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withCount(['address', 'foo' => function ($query) { + $query->where('active', false); + }]); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "address_count", (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and "active" = ?) as "foo_count" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountAndMergedWheres() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->select('id')->withCount(['activeFoo' => function ($q) { + $q->where('bam', '>', 'qux'); + }]); + + $this->assertSame('select "id", (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and "bam" > ? and "active" = ?) as "active_foo_count" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + $this->assertEquals(['qux', true], $builder->getBindings()); + } + + public function testWithCountAndGlobalScope() + { + $model = new EloquentBuilderTestModelParentStub; + EloquentBuilderTestModelCloseRelatedStub::addGlobalScope('withCount', function ($query) { + return $query->addSelect('id'); + }); + + $builder = $model->select('id')->withCount(['foo']); + + // Remove the global scope so it doesn't interfere with any other tests + EloquentBuilderTestModelCloseRelatedStub::addGlobalScope('withCount', function ($query) { + // + }); + + $this->assertSame('select "id", (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_count" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithMin() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withMin('foo', 'price'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select min("eloquent_builder_test_model_close_related_stubs"."price") from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_min_price" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithMinExpression() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withMin('foo', new Expression('price - discount')); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select min(price - discount) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_min_price_discount" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithMinOnBelongsToMany() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withMin('roles', 'id'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select min("eloquent_builder_test_model_far_related_stubs"."id") from "eloquent_builder_test_model_far_related_stubs" inner join "user_role" on "eloquent_builder_test_model_far_related_stubs"."id" = "user_role"."related_id" where "eloquent_builder_test_model_parent_stubs"."id" = "user_role"."self_id") as "roles_min_id" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithMinOnSelfRelated() + { + $model = new EloquentBuilderTestModelSelfRelatedStub; + + $sql = $model->withMin('childFoos', 'created_at')->toSql(); + + // alias has a dynamic hash, so replace with a static string for comparison + $alias = 'self_alias_hash'; + $aliasRegex = '/\b(laravel_reserved_\d)(\b|$)/i'; + + $sql = preg_replace($aliasRegex, $alias, $sql); + + $this->assertSame('select "self_related_stubs".*, (select min("self_alias_hash"."created_at") from "self_related_stubs" as "self_alias_hash" where "self_related_stubs"."id" = "self_alias_hash"."parent_id") as "child_foos_min_created_at" from "self_related_stubs"', $sql); + } + + public function testWithMax() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withMax('foo', 'price'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select max("eloquent_builder_test_model_close_related_stubs"."price") from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_max_price" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithMaxExpression() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withMax('foo', new Expression('price - discount')); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select max(price - discount) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_max_price_discount" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithAvg() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withAvg('foo', 'price'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select avg("eloquent_builder_test_model_close_related_stubs"."price") from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_avg_price" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWitAvgExpression() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withAvg('foo', new Expression('price - discount')); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select avg(price - discount) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_avg_price_discount" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountAndConstraintsAndHaving() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->where('bar', 'baz'); + $builder->withCount(['foo' => function ($q) { + $q->where('bam', '>', 'qux'); + }])->having('foo_count', '>=', 1); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and "bam" > ?) as "foo_count" from "eloquent_builder_test_model_parent_stubs" where "bar" = ? having "foo_count" >= ?', $builder->toSql()); + $this->assertEquals(['qux', 'baz', 1], $builder->getBindings()); + } + + public function testWithCountAndRename() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withCount('foo as foo_bar'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_bar" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountMultipleAndPartialRename() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withCount(['foo as foo_bar', 'foo']); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_bar", (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_count" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithAggregateAlias() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withAggregate('foo', new Expression('TIMESTAMPDIFF(SECOND, `created_at`, `updated_at`)'), 'sum'); + + $this->assertSame( + 'select "eloquent_builder_test_model_parent_stubs".*, (select sum(TIMESTAMPDIFF(SECOND, `created_at`, `updated_at`)) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_sum_timestampdiffsecond_created_at_updated_at" from "eloquent_builder_test_model_parent_stubs"', + $builder->toSql() + ); + } + + public function testWithAggregateAndSelfRelationConstrain() + { + EloquentBuilderTestStub::resolveRelationUsing('children', function ($model) { + return $model->hasMany(EloquentBuilderTestStub::class, 'parent_id', 'id')->where('enum_value', new stdClass); + }); + + $model = new EloquentBuilderTestStub; + $this->mockConnectionForModel($model, ''); + $relationHash = $model->children()->getRelationCountHash(false); + + $builder = $model->withCount('children'); + + $this->assertSame(vsprintf('select "table".*, (select count(*) from "table" as "%s" where "table"."id" = "%s"."parent_id" and "enum_value" = ?) as "children_count" from "table"', [$relationHash, $relationHash]), $builder->toSql()); + } + + public function testWithExists() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withExists('foo'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsAndSelect() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->select('id')->withExists('foo'); + + $this->assertSame('select "id", exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsAndMergedWheres() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->select('id')->withExists(['activeFoo' => function ($q) { + $q->where('bam', '>', 'qux'); + }]); + + $this->assertSame('select "id", exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and "bam" > ? and "active" = ?) as "active_foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + $this->assertEquals(['qux', true], $builder->getBindings()); + } + + public function testWithExistsAndGlobalScope() + { + $model = new EloquentBuilderTestModelParentStub; + EloquentBuilderTestModelCloseRelatedStub::addGlobalScope('withExists', function ($query) { + return $query->addSelect('id'); + }); + + $builder = $model->select('id')->withExists(['foo']); + + // Remove the global scope so it doesn't interfere with any other tests + EloquentBuilderTestModelCloseRelatedStub::addGlobalScope('withExists', function ($query) { + // + }); + + $this->assertSame('select "id", exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsOnBelongsToMany() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withExists('roles'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_far_related_stubs" inner join "user_role" on "eloquent_builder_test_model_far_related_stubs"."id" = "user_role"."related_id" where "eloquent_builder_test_model_parent_stubs"."id" = "user_role"."self_id") as "roles_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsOnSelfRelated() + { + $model = new EloquentBuilderTestModelSelfRelatedStub; + + $sql = $model->withExists('childFoos')->toSql(); + + // alias has a dynamic hash, so replace with a static string for comparison + $alias = 'self_alias_hash'; + $aliasRegex = '/\b(laravel_reserved_\d)(\b|$)/i'; + + $sql = preg_replace($aliasRegex, $alias, $sql); + + $this->assertSame('select "self_related_stubs".*, exists(select * from "self_related_stubs" as "self_alias_hash" where "self_related_stubs"."id" = "self_alias_hash"."parent_id") as "child_foos_exists" from "self_related_stubs"', $sql); + } + + public function testWithExistsAndRename() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withExists('foo as foo_bar'); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_bar" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsMultipleAndPartialRename() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->withExists(['foo as foo_bar', 'foo']); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_bar", exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + } + + public function testHasWithConstraintsAndHavingInSubquery() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->where('bar', 'baz'); + $builder->whereHas('foo', function ($q) { + $q->having('bam', '>', 'qux'); + })->where('quux', 'quuux'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? and exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" having "bam" > ?) and "quux" = ?', $builder->toSql()); + $this->assertEquals(['baz', 'qux', 'quuux'], $builder->getBindings()); + } + + public function testHasWithConstraintsWithOrWhereAndHavingInSubquery() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->where('name', 'larry'); + $builder->whereHas('address', function ($q) { + $q->where('zipcode', '90210'); + $q->orWhere('zipcode', '90220'); + $q->having('street', '=', 'fooside dr'); + })->where('age', 29); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "name" = ? and exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("zipcode" = ? or "zipcode" = ?) having "street" = ?) and "age" = ?', $builder->toSql()); + $this->assertEquals(['larry', '90210', '90220', 'fooside dr', 29], $builder->getBindings()); + } + + public function testHasWithConstraintsWithOrWhereAndSubqueryInRelationFromClause() + { + EloquentBuilderTestModelParentStub::resolveRelationUsing('addressAsExpression', function ($model) { + return $model->address()->fromSub(EloquentBuilderTestModelCloseRelatedStub::query(), 'eloquent_builder_test_model_close_related_stubs'); + }); + + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->where('name', 'larry'); + $builder->whereHas('addressAsExpression', function ($q) { + $q->where('zipcode', '90210'); + $q->orWhere('zipcode', '90220'); + $q->having('street', '=', 'fooside dr'); + })->where('age', 29); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "name" = ? and exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("zipcode" = ? or "zipcode" = ?) having "street" = ?) and "age" = ?', $builder->toSql()); + $this->assertEquals(['larry', '90210', '90220', 'fooside dr', 29], $builder->getBindings()); + } + + public function testHasWithConstraintsAndJoinAndHavingInSubquery() + { + $model = new EloquentBuilderTestModelParentStub; + $builder = $model->where('bar', 'baz'); + $builder->whereHas('foo', function ($q) { + $q->join('quuuux', function ($j) { + $j->where('quuuuux', '=', 'quuuuuux'); + }); + $q->having('bam', '>', 'qux'); + })->where('quux', 'quuux'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? and exists (select * from "eloquent_builder_test_model_close_related_stubs" inner join "quuuux" on "quuuuux" = ? where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" having "bam" > ?) and "quux" = ?', $builder->toSql()); + $this->assertEquals(['baz', 'quuuuuux', 'qux', 'quuux'], $builder->getBindings()); + } + + public function testHasWithConstraintsAndHavingInSubqueryWithCount() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->where('bar', 'baz'); + $builder->whereHas('foo', function ($q) { + $q->having('bam', '>', 'qux'); + }, '>=', 2)->where('quux', 'quuux'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? and (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" having "bam" > ?) >= 2 and "quux" = ?', $builder->toSql()); + $this->assertEquals(['baz', 'qux', 'quuux'], $builder->getBindings()); + } + + public function testWithCountAndConstraintsWithBindingInSelectSub() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->newQuery(); + $builder->withCount(['foo' => function ($q) use ($model) { + $q->selectSub($model->newQuery()->where('bam', '=', 3)->selectRaw('count(0)'), 'bam_3_count'); + }]); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, (select count(*) from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_count" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + + public function testWithExistsAndConstraintsWithBindingInSelectSub() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->newQuery(); + $builder->withExists(['foo' => function ($q) use ($model) { + $q->selectSub($model->newQuery()->where('bam', '=', 3)->selectRaw('count(0)'), 'bam_3_count'); + }]); + + $this->assertSame('select "eloquent_builder_test_model_parent_stubs".*, exists(select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id") as "foo_exists" from "eloquent_builder_test_model_parent_stubs"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + + public function testHasNestedWithConstraints() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->whereHas('foo', function ($q) { + $q->whereHas('bar', function ($q) { + $q->where('baz', 'bam'); + }); + })->toSql(); + + $result = $model->whereHas('foo.bar', function ($q) { + $q->where('baz', 'bam'); + })->toSql(); + + $this->assertEquals($builder, $result); + } + + public function testHasNested() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->whereHas('foo', function ($q) { + $q->has('bar'); + }); + + $result = $model->has('foo.bar')->toSql(); + + $this->assertEquals($builder->toSql(), $result); + } + + public function testHasNestedWithMorphTo() + { + $model = new EloquentBuilderTestModelParentStub; + $connection = $this->mockConnectionForModel($model, ''); + + $morphToKey = $model->morph()->getMorphType(); + + $connection->shouldReceive('select')->once()->andReturn([ + [$morphToKey => EloquentBuilderTestModelFarRelatedStub::class], + [$morphToKey => EloquentBuilderTestModelOtherFarRelatedStub::class], + ]); + + $builder = $model->orWhereHasMorph('morph', [EloquentBuilderTestModelFarRelatedStub::class], function ($q) { + $q->has('baz'); + })->orWhereHasMorph('morph', [EloquentBuilderTestModelOtherFarRelatedStub::class], function ($q) { + $q->has('baz'); + }); + + $results = $model->has('morph.baz')->toSql(); + + // we need to adjust the expected builder because some parathesis are added, + // which doesn't impact the behavior of the test. + + $builderSql = $builder->toSql(); + $builderSql = str_replace(')))) or ((', '))) or (', $builderSql); + + $this->assertSame($builderSql, $results); + } + + public function testHasNestedWithMorphToAndMultipleSubRelations() + { + $model = new EloquentBuilderTestModelParentStub; + $connection = $this->mockConnectionForModel($model, ''); + + $morphToKey = $model->morph()->getMorphType(); + + $connection->shouldReceive('select')->once()->andReturn([ + [$morphToKey => EloquentBuilderTestModelFarRelatedStub::class], + [$morphToKey => EloquentBuilderTestModelOtherFarRelatedStub::class], + ]); + + $builder = $model->orWhereHasMorph('morph', [EloquentBuilderTestModelFarRelatedStub::class], function ($q) { + $q->has('baz.bam'); + })->orWhereHasMorph('morph', [EloquentBuilderTestModelOtherFarRelatedStub::class], function ($q) { + $q->has('baz.bam'); + }); + + $results = $model->has('morph.baz.bam')->toSql(); + + // we need to adjust the expected builder because some parathesis are added, + // which doesn't impact the behavior of the test. + + $builderSql = $builder->toSql(); + $builderSql = str_replace(')))) or ((', '))) or (', $builderSql); + + $this->assertSame($builderSql, $results); + } + + public function testOrHasNested() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->whereHas('foo', function ($q) { + $q->has('bar'); + })->orWhereHas('foo', function ($q) { + $q->has('baz'); + }); + + $result = $model->has('foo.bar')->orHas('foo.baz')->toSql(); + + $this->assertEquals($builder->toSql(), $result); + } + + public function testSelfHasNested() + { + $model = new EloquentBuilderTestModelSelfRelatedStub; + + $nestedSql = $model->whereHas('parentFoo', function ($q) { + $q->has('childFoo'); + })->toSql(); + + $dotSql = $model->has('parentFoo.childFoo')->toSql(); + + // alias has a dynamic hash, so replace with a static string for comparison + $alias = 'self_alias_hash'; + $aliasRegex = '/\b(laravel_reserved_\d)(\b|$)/i'; + + $nestedSql = preg_replace($aliasRegex, $alias, $nestedSql); + $dotSql = preg_replace($aliasRegex, $alias, $dotSql); + + $this->assertEquals($nestedSql, $dotSql); + } + + public function testSelfHasNestedUsesAlias() + { + $model = new EloquentBuilderTestModelSelfRelatedStub; + + $sql = $model->has('parentFoo.childFoo')->toSql(); + + // alias has a dynamic hash, so replace with a static string for comparison + $alias = 'self_alias_hash'; + $aliasRegex = '/\b(laravel_reserved_\d)(\b|$)/i'; + + $sql = preg_replace($aliasRegex, $alias, $sql); + + $this->assertStringContainsString('"self_alias_hash"."id" = "self_related_stubs"."parent_id"', $sql); + } + + public function testDoesntHave() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->doesntHave('foo'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where not exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id")', $builder->toSql()); + } + + public function testDoesntHaveNested() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->doesntHave('foo.bar'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where not exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and exists (select * from "eloquent_builder_test_model_far_related_stubs" where "eloquent_builder_test_model_close_related_stubs"."id" = "eloquent_builder_test_model_far_related_stubs"."eloquent_builder_test_model_close_related_stub_id"))', $builder->toSql()); + } + + public function testOrDoesntHave() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->where('bar', 'baz')->orDoesntHave('foo'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or not exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id")', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testWhereDoesntHave() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->whereDoesntHave('foo', function ($query) { + $query->where('bar', 'baz'); + }); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where not exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and "bar" = ?)', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testOrWhereDoesntHave() + { + $model = new EloquentBuilderTestModelParentStub; + + $builder = $model->where('bar', 'baz')->orWhereDoesntHave('foo', function ($query) { + $query->where('qux', 'quux'); + }); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or not exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and "qux" = ?)', $builder->toSql()); + $this->assertEquals(['baz', 'quux'], $builder->getBindings()); + } + + public function testWhereMorphedTo() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $relatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $relatedModel->id = 1; + + $builder = $model->whereMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where (("eloquent_builder_test_model_parent_stubs"."morph_type" = ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereMorphedToCollection() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $firstRelatedModel->id = 1; + + $secondRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $secondRelatedModel->id = 2; + + $builder = $model->whereMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where (("eloquent_builder_test_model_parent_stubs"."morph_type" = ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertEquals([$firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereMorphedToCollectionWithDifferentModels() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $firstRelatedModel->id = 1; + + $secondRelatedModel = new EloquentBuilderTestModelFarRelatedStub; + $secondRelatedModel->id = 2; + + $thirdRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $thirdRelatedModel->id = 3; + + $builder = $model->whereMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where (("eloquent_builder_test_model_parent_stubs"."morph_type" = ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?, ?)) or ("eloquent_builder_test_model_parent_stubs"."morph_type" = ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $thirdRelatedModel->getKey(), $secondRelatedModel->getMorphClass(), $secondRelatedModel->id], $builder->getBindings()); + } + + public function testWhereMorphedToNull() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $builder = $model->whereMorphedTo('morph', null); + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "eloquent_builder_test_model_parent_stubs"."morph_type" is null', $builder->toSql()); + } + + public function testWhereNotMorphedTo() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $relatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $relatedModel->id = 1; + + $builder = $model->whereNotMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where not (("eloquent_builder_test_model_parent_stubs"."morph_type" <=> ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereNotMorphedToCollection() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $firstRelatedModel->id = 1; + + $secondRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $secondRelatedModel->id = 2; + + $builder = $model->whereNotMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where not (("eloquent_builder_test_model_parent_stubs"."morph_type" <=> ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertEquals([$firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereNotMorphedToCollectionWithDifferentModels() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $firstRelatedModel->id = 1; + + $secondRelatedModel = new EloquentBuilderTestModelFarRelatedStub; + $secondRelatedModel->id = 2; + + $thirdRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $thirdRelatedModel->id = 3; + + $builder = $model->whereNotMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where not (("eloquent_builder_test_model_parent_stubs"."morph_type" <=> ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?, ?)) or ("eloquent_builder_test_model_parent_stubs"."morph_type" <=> ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $thirdRelatedModel->getKey(), $secondRelatedModel->getMorphClass(), $secondRelatedModel->id], $builder->getBindings()); + } + + public function testOrWhereMorphedTo() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $relatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $relatedModel->id = 1; + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or (("eloquent_builder_test_model_parent_stubs"."morph_type" = ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals(['baz', $relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testOrWhereMorphedToCollection() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $firstRelatedModel->id = 1; + + $secondRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $secondRelatedModel->id = 2; + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or (("eloquent_builder_test_model_parent_stubs"."morph_type" = ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertEquals(['baz', $firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); + } + + public function testOrWhereMorphedToCollectionWithDifferentModels() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $firstRelatedModel->id = 1; + + $secondRelatedModel = new EloquentBuilderTestModelFarRelatedStub; + $secondRelatedModel->id = 2; + + $thirdRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $thirdRelatedModel->id = 3; + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or (("eloquent_builder_test_model_parent_stubs"."morph_type" = ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?, ?)) or ("eloquent_builder_test_model_parent_stubs"."morph_type" = ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals(['baz', $firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $thirdRelatedModel->getKey(), $secondRelatedModel->getMorphClass(), $secondRelatedModel->id], $builder->getBindings()); + } + + public function testOrWhereMorphedToNull() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', null); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or "eloquent_builder_test_model_parent_stubs"."morph_type" is null', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testOrWhereNotMorphedTo() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $relatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $relatedModel->id = 1; + + $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or not (("eloquent_builder_test_model_parent_stubs"."morph_type" <=> ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals(['baz', $relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testOrWhereNotMorphedToCollection() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $firstRelatedModel->id = 1; + + $secondRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $secondRelatedModel->id = 2; + + $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or not (("eloquent_builder_test_model_parent_stubs"."morph_type" <=> ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertEquals(['baz', $firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); + } + + public function testOrWhereNotMorphedToCollectionWithDifferentModels() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $firstRelatedModel->id = 1; + + $secondRelatedModel = new EloquentBuilderTestModelFarRelatedStub; + $secondRelatedModel->id = 2; + + $thirdRelatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $thirdRelatedModel->id = 3; + + $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or not (("eloquent_builder_test_model_parent_stubs"."morph_type" <=> ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?, ?)) or ("eloquent_builder_test_model_parent_stubs"."morph_type" <=> ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals(['baz', $firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $thirdRelatedModel->getKey(), $secondRelatedModel->getMorphClass(), $secondRelatedModel->id], $builder->getBindings()); + } + + public function testWhereMorphedToClass() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $builder = $model->whereMorphedTo('morph', EloquentBuilderTestModelCloseRelatedStub::class); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "eloquent_builder_test_model_parent_stubs"."morph_type" = ?', $builder->toSql()); + $this->assertEquals([EloquentBuilderTestModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testWhereNotMorphedToClass() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $builder = $model->whereNotMorphedTo('morph', EloquentBuilderTestModelCloseRelatedStub::class); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where not "eloquent_builder_test_model_parent_stubs"."morph_type" <=> ?', $builder->toSql()); + $this->assertEquals([EloquentBuilderTestModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testOrWhereMorphedToClass() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', EloquentBuilderTestModelCloseRelatedStub::class); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or "eloquent_builder_test_model_parent_stubs"."morph_type" = ?', $builder->toSql()); + $this->assertEquals(['baz', EloquentBuilderTestModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testOrWhereNotMorphedToClass() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', EloquentBuilderTestModelCloseRelatedStub::class); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "bar" = ? or not "eloquent_builder_test_model_parent_stubs"."morph_type" <=> ?', $builder->toSql()); + $this->assertEquals(['baz', EloquentBuilderTestModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testWhereNotMorphedToWithSQLite() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, 'SQLite'); + + $relatedModel = new EloquentBuilderTestModelCloseRelatedStub; + $relatedModel->id = 1; + + $builder = $model->whereNotMorphedTo('morph', $relatedModel); + + $this->assertStringNotContainsString('<=>', $builder->toSql()); + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where not (("eloquent_builder_test_model_parent_stubs"."morph_type" IS ? and "eloquent_builder_test_model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereNotMorphedToClassWithSQLite() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, 'SQLite'); + + $builder = $model->whereNotMorphedTo('morph', EloquentBuilderTestModelCloseRelatedStub::class); + + $this->assertStringNotContainsString('<=>', $builder->toSql()); + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where not "eloquent_builder_test_model_parent_stubs"."morph_type" IS ?', $builder->toSql()); + $this->assertEquals([EloquentBuilderTestModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testWhereMorphedToAlias() + { + $model = new EloquentBuilderTestModelParentStub; + $this->mockConnectionForModel($model, ''); + + Relation::morphMap([ + 'alias' => EloquentBuilderTestModelCloseRelatedStub::class, + ]); + + $builder = $model->whereMorphedTo('morph', EloquentBuilderTestModelCloseRelatedStub::class); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "eloquent_builder_test_model_parent_stubs"."morph_type" = ?', $builder->toSql()); + $this->assertEquals(['alias'], $builder->getBindings()); + + Relation::morphMap([], false); + } + + public function testWhereKeyMethodWithInt() + { + $model = $this->getMockModel(); + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $int = 1; + + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '=', $int); + + $builder->whereKey($int); + } + + public function testWhereKeyMethodWithStringZero() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $int = 0; + + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '=', (string) $int); + + $builder->whereKey($int); + } + + public function testWhereKeyMethodWithStringNull() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '=', m::on(function ($argument) { + return $argument === null; + })); + + $builder->whereKey(null); + } + + public function testWhereKeyMethodWithArray() + { + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->andReturn('int'); + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $array = [1, 2, 3]; + + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with($keyName, $array); + + $builder->whereKey($array); + } + + public function testWhereKeyMethodWithCollection() + { + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->andReturn('int'); + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $collection = new Collection([1, 2, 3]); + + $builder->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with($keyName, $collection); + + $builder->whereKey($collection); + } + + public function testWhereKeyMethodWithModel() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '=', m::on(function ($argument) { + return $argument === '1'; + })); + + $builder->whereKey(new class extends Model + { + protected array $attributes = ['id' => 1]; + }); + } + + public function testWhereKeyNotMethodWithStringZero() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $int = 0; + + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '!=', (string) $int); + + $builder->whereKeyNot($int); + } + + public function testWhereKeyNotMethodWithStringNull() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '!=', m::on(function ($argument) { + return $argument === null; + })); + + $builder->whereKeyNot(null); + } + + public function testWhereKeyNotMethodWithInt() + { + $model = $this->getMockModel(); + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $int = 1; + + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '!=', $int); + + $builder->whereKeyNot($int); + } + + public function testWhereKeyNotMethodWithArray() + { + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->andReturn('int'); + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $array = [1, 2, 3]; + + $builder->getQuery()->shouldReceive('whereIntegerNotInRaw')->once()->with($keyName, $array); + + $builder->whereKeyNot($array); + } + + public function testWhereKeyNotMethodWithCollection() + { + $model = $this->getMockModel(); + $model->shouldReceive('getKeyType')->andReturn('int'); + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $collection = new Collection([1, 2, 3]); + + $builder->getQuery()->shouldReceive('whereIntegerNotInRaw')->once()->with($keyName, $collection); + + $builder->whereKeyNot($collection); + } + + public function testWhereKeyNotMethodWithModel() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '!=', m::on(function ($argument) { + return $argument === '1'; + })); + + $builder->whereKeyNot(new class extends Model + { + protected array $attributes = ['id' => 1]; + }); + } + + public function testExceptMethodWithModel() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $builder->getQuery()->shouldReceive('where')->once()->with($keyName, '!=', m::on(function ($argument) { + return $argument === '1'; + })); + + $builder->except(new class extends Model + { + protected array $attributes = ['id' => 1]; + }); + } + + public function testExceptMethodWithCollectionOfModel() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $builder->getQuery()->shouldReceive('whereNotIn')->once()->with($keyName, m::on(function ($argument) { + return $argument === [1, 2]; + })); + + $models = new Collection([ + new class extends Model + { + protected array $attributes = ['id' => 1]; + }, + new class extends Model + { + protected array $attributes = ['id' => 2]; + }, + ]); + + $builder->except($models); + } + + public function testExceptMethodWithArrayOfModel() + { + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder = $this->getBuilder()->setModel($model); + $keyName = $model->getQualifiedKeyName(); + + $builder->getQuery()->shouldReceive('whereNotIn')->once()->with($keyName, m::on(function ($argument) { + return $argument === [1, 2]; + })); + + $models = [ + new class extends Model + { + protected array $attributes = ['id' => 1]; + }, + new class extends Model + { + protected array $attributes = ['id' => 2]; + }, + ]; + + $builder->except($models); + } + + public function testWhereIn() + { + $model = new EloquentBuilderTestNestedStub; + $this->mockConnectionForModel($model, ''); + $query = $model->newQuery()->withoutGlobalScopes()->whereIn('foo', $model->newQuery()->select('id')); + $expected = 'select * from "table" where "foo" in (select "id" from "table" where "table"."deleted_at" is null)'; + $this->assertEquals($expected, $query->toSql()); + } + + public function testLatestWithoutColumnWithCreatedAt() + { + $model = $this->getMockModel(); + $model->shouldReceive('getCreatedAtColumn')->andReturn('foo'); + $builder = $this->getBuilder()->setModel($model); + + $builder->getQuery()->shouldReceive('latest')->once()->with('foo'); + + $builder->latest(); + } + + public function testLatestWithoutColumnWithoutCreatedAt() + { + $model = $this->getMockModel(); + $model->shouldReceive('getCreatedAtColumn')->andReturn(null); + $builder = $this->getBuilder()->setModel($model); + + $builder->getQuery()->shouldReceive('latest')->once()->with('created_at'); + + $builder->latest(); + } + + public function testLatestWithColumn() + { + $model = $this->getMockModel(); + $builder = $this->getBuilder()->setModel($model); + + $builder->getQuery()->shouldReceive('latest')->once()->with('foo'); + + $builder->latest('foo'); + } + + public function testOldestWithoutColumnWithCreatedAt() + { + $model = $this->getMockModel(); + $model->shouldReceive('getCreatedAtColumn')->andReturn('foo'); + $builder = $this->getBuilder()->setModel($model); + + $builder->getQuery()->shouldReceive('oldest')->once()->with('foo'); + + $builder->oldest(); + } + + public function testOldestWithoutColumnWithoutCreatedAt() + { + $model = $this->getMockModel(); + $model->shouldReceive('getCreatedAtColumn')->andReturn(null); + $builder = $this->getBuilder()->setModel($model); + + $builder->getQuery()->shouldReceive('oldest')->once()->with('created_at'); + + $builder->oldest(); + } + + public function testOldestWithColumn() + { + $model = $this->getMockModel(); + $builder = $this->getBuilder()->setModel($model); + + $builder->getQuery()->shouldReceive('oldest')->once()->with('foo'); + + $builder->oldest('foo'); + } + + public function testUpdate() + { + Carbon::setTestNow($now = '2017-10-10 10:10:10'); + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new BaseBuilder($connection, new Grammar($connection), m::mock(Processor::class)); + $builder = new Builder($query); + $model = new EloquentBuilderTestStub; + $this->mockConnectionForModel($model, ''); + $builder->setModel($model); + $builder->getConnection()->shouldReceive('update')->once() + ->with('update "table" set "foo" = ?, "table"."updated_at" = ?', ['bar', $now])->andReturn(1); + + $result = $builder->update(['foo' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateWithTimestampValue() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new BaseBuilder($connection, new Grammar($connection), m::mock(Processor::class)); + $builder = new Builder($query); + $model = new EloquentBuilderTestStub; + $this->mockConnectionForModel($model, ''); + $builder->setModel($model); + $builder->getConnection()->shouldReceive('update')->once() + ->with('update "table" set "foo" = ?, "table"."updated_at" = ?', ['bar', null])->andReturn(1); + + $result = $builder->update(['foo' => 'bar', 'updated_at' => null]); + $this->assertEquals(1, $result); + } + + public function testUpdateWithQualifiedTimestampValue() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new BaseBuilder($connection, new Grammar($connection), m::mock(Processor::class)); + $builder = new Builder($query); + $model = new EloquentBuilderTestStub; + $this->mockConnectionForModel($model, ''); + $builder->setModel($model); + $builder->getConnection()->shouldReceive('update')->once() + ->with('update "table" set "table"."foo" = ?, "table"."updated_at" = ?', ['bar', null])->andReturn(1); + + $result = $builder->update(['table.foo' => 'bar', 'table.updated_at' => null]); + $this->assertEquals(1, $result); + } + + public function testUpdateWithoutTimestamp() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new BaseBuilder($connection, new Grammar($connection), m::mock(Processor::class)); + $builder = new Builder($query); + $model = new EloquentBuilderTestStubWithoutTimestamp; + $this->mockConnectionForModel($model, ''); + $builder->setModel($model); + $builder->getConnection()->shouldReceive('update')->once() + ->with('update "table" set "foo" = ?', ['bar'])->andReturn(1); + + $result = $builder->update(['foo' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateWithAlias() + { + Carbon::setTestNow($now = '2017-10-10 10:10:10'); + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new BaseBuilder($connection, new Grammar($connection), m::mock(Processor::class)); + $builder = new Builder($query); + $model = new EloquentBuilderTestStub; + $this->mockConnectionForModel($model, ''); + $builder->setModel($model); + $builder->getConnection()->shouldReceive('update')->once() + ->with('update "table" as "alias" set "foo" = ?, "alias"."updated_at" = ?', ['bar', $now])->andReturn(1); + + $result = $builder->from('table as alias')->update(['foo' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateWithAliasWithQualifiedTimestampValue() + { + Carbon::setTestNow($now = '2017-10-10 10:10:10'); + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new BaseBuilder($connection, new Grammar($connection), m::mock(Processor::class)); + $builder = new Builder($query); + $model = new EloquentBuilderTestStub; + $this->mockConnectionForModel($model, ''); + $builder->setModel($model); + $builder->getConnection()->shouldReceive('update')->once() + ->with('update "table" as "alias" set "foo" = ?, "alias"."updated_at" = ?', ['bar', null])->andReturn(1); + + $result = $builder->from('table as alias')->update(['foo' => 'bar', 'alias.updated_at' => null]); + $this->assertEquals(1, $result); + + Carbon::setTestNow(null); + } + + public function testUpsert() + { + Carbon::setTestNow($now = '2017-10-10 10:10:10'); + + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('foo_table')->andReturnSelf(); + $query->from = 'foo_table'; + + $builder = new Builder($query); + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder->setModel($model); + + $query->shouldReceive('upsert')->once() + ->with([ + ['email' => 'foo', 'name' => 'bar', 'updated_at' => $now, 'created_at' => $now], + ['name' => 'bar2', 'email' => 'foo2', 'updated_at' => $now, 'created_at' => $now], + ], ['email'], ['email', 'name', 'updated_at'])->andReturn(2); + + $result = $builder->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], ['email']); + + $this->assertEquals(2, $result); + } + + public function testTouch() + { + Carbon::setTestNow($now = '2017-10-10 10:10:10'); + + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('foo_table')->andReturnSelf(); + $query->from = 'foo_table'; + + $builder = new Builder($query); + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder->setModel($model); + + $query->shouldReceive('update')->once()->with(['updated_at' => $now])->andReturn(2); + + $result = $builder->touch(); + + $this->assertEquals(2, $result); + } + + public function testTouchWithCustomColumn() + { + Carbon::setTestNow($now = '2017-10-10 10:10:10'); + + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('foo_table')->andReturnSelf(); + $query->from = 'foo_table'; + + $builder = new Builder($query); + $model = new EloquentBuilderTestStubStringPrimaryKey; + $builder->setModel($model); + + $query->shouldReceive('update')->once()->with(['published_at' => $now])->andReturn(2); + + $result = $builder->touch('published_at'); + + $this->assertEquals(2, $result); + } + + public function testTouchWithoutUpdatedAtColumn() + { + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('table')->andReturnSelf(); + $query->from = 'table'; + + $builder = new Builder($query); + $model = new EloquentBuilderTestStubWithoutTimestamp; + $builder->setModel($model); + + $query->shouldNotReceive('update'); + + $result = $builder->touch(); + + $this->assertFalse($result); + } + + public function testWithCastsMethod() + { + $builder = new Builder($this->getMockQueryBuilder()); + $model = $this->getMockModel(); + $builder->setModel($model); + + $model->shouldReceive('mergeCasts')->with(['foo' => 'bar'])->once(); + $builder->withCasts(['foo' => 'bar']); + } + + public function testClone() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new BaseBuilder($connection, new Grammar($connection), m::mock(Processor::class)); + $builder = new Builder($query); + $builder->select('*')->from('users'); + $clone = $builder->clone()->where('email', 'foo'); + + $this->assertNotSame($builder, $clone); + $this->assertSame('select * from "users"', $builder->toSql()); + $this->assertSame('select * from "users" where "email" = ?', $clone->toSql()); + } + + public function testCloneModelMakesAFreshCopyOfTheModel() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new BaseBuilder($connection, new Grammar($connection), m::mock(Processor::class)); + $builder = (new Builder($query))->setModel(new EloquentBuilderTestStub); + $builder->select('*')->from('users'); + + $onCloneCallbackCalledCount = 0; + + $onCloneQuery = null; + + $builder->onClone(function (Builder $query) use (&$onCloneCallbackCalledCount, &$onCloneQuery) { + $onCloneCallbackCalledCount++; + + $onCloneQuery = $query; + }); + + $clone = $builder->clone()->where('email', 'foo'); + + $this->assertNotSame($builder, $clone); + $this->assertSame('select * from "users"', $builder->toSql()); + $this->assertSame('select * from "users" where "email" = ?', $clone->toSql()); + + $this->assertSame(1, $onCloneCallbackCalledCount); + $this->assertSame($onCloneQuery, $clone); + } + + public function testToRawSql() + { + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('toRawSql') + ->andReturn('select * from "users" where "email" = \'foo\''); + + $builder = new Builder($query); + + $this->assertSame('select * from "users" where "email" = \'foo\'', $builder->toRawSql()); + } + + public function testPassthruMethodsCallsAreNotCaseSensitive() + { + $query = m::mock(BaseBuilder::class); + + $mockResponse = 'select 1'; + $query + ->shouldReceive('toRawSql') + ->andReturn($mockResponse) + ->times(3); + + $builder = new Builder($query); + + $this->assertSame('select 1', $builder->TORAWSQL()); + $this->assertSame('select 1', $builder->toRawSql()); + $this->assertSame('select 1', $builder->toRawSQL()); + } + + public function testPassthruArrayElementsMustAllBeLowercase() + { + $builder = new class(m::mock(BaseBuilder::class)) extends Builder + { + // expose protected member for test + public function getPassthru(): array + { + return $this->passthru; + } + }; + + $passthru = $builder->getPassthru(); + + foreach ($passthru as $method) { + $lowercaseMethod = strtolower($method); + + $this->assertSame( + $lowercaseMethod, + $method, + 'Eloquent\\Builder relies on lowercase method names in $passthru array to correctly mimic PHP case-insensitivity on method dispatch.'. + 'If you are adding a new method to the $passthru array, make sure the name is lowercased.' + ); + } + } + + public function testPipeCallback() + { + $query = new Builder(new BaseBuilder( + $connection = new Connection(new PDO('sqlite::memory:')), + new Grammar($connection), + new Processor, + )); + + $result = $query->pipe(fn (Builder $query) => 5); + $this->assertSame(5, $result); + + $result = $query->pipe(fn (Builder $query) => null); + $this->assertSame($query, $result); + + $result = $query->pipe(function (Builder $query) { + // + }); + $this->assertSame($query, $result); + + $this->assertCount(0, $query->getQuery()->wheres); + $result = $query->pipe(fn (Builder $query) => $query->where('foo', 'bar')); + $this->assertSame($query, $result); + $this->assertCount(1, $query->getQuery()->wheres); + } + + protected function mockConnectionForModel($model, $database) + { + $grammarClass = 'Hypervel\Database\Query\Grammars\\'.$database.'Grammar'; + $processorClass = 'Hypervel\Database\Query\Processors\\'.$database.'Processor'; + $processor = new $processorClass; + $connection = m::mock(Connection::class, ['getPostProcessor' => $processor]); + $grammar = new $grammarClass($connection); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new BaseBuilder($connection, $grammar, $processor); + }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $resolver = m::mock(ConnectionResolverInterface::class, ['connection' => $connection]); + $class = get_class($model); + $class::setConnectionResolver($resolver); + + return $connection; + } + + protected function getBuilder() + { + return new Builder($this->getMockQueryBuilder()); + } + + protected function getMockModel() + { + $model = m::mock(Model::class); + $model->shouldReceive('getKeyName')->andReturn('foo'); + $model->shouldReceive('getTable')->andReturn('foo_table'); + $model->shouldReceive('getQualifiedKeyName')->andReturn('foo_table.foo'); + + return $model; + } + + protected function getMockQueryBuilder() + { + $query = m::mock(BaseBuilder::class); + $query->shouldReceive('from')->with('foo_table'); + + return $query; + } +} + +class EloquentBuilderTestStub extends Model +{ + protected ?string $table = 'table'; +} + +class EloquentBuilderTestScopeStub extends Model +{ + public function scopeApproved($query) + { + $query->where('foo', 'bar'); + } +} + +class EloquentBuilderTestDynamicScopeStub extends Model +{ + public function scopeDynamic($query, $foo = 'foo', $bar = 'bar') + { + $query->where($foo, $bar); + } +} + +class EloquentBuilderTestHigherOrderWhereScopeStub extends Model +{ + protected ?string $table = 'table'; + + public function scopeOne($query) + { + $query->where('one', 'foo'); + } + + public function scopeTwo($query) + { + $query->where('two', 'bar'); + } + + public function scopeThree($query) + { + $query->where('three', 'baz'); + } +} + +class EloquentBuilderTestNestedStub extends Model +{ + use SoftDeletes; + + protected ?string $table = 'table'; + + public function scopeEmpty($query) + { + return $query; + } +} + +class EloquentBuilderTestPluckStub extends Model +{ + public function __construct(array $attributes = []) + { + // Don't call parent - directly set attributes for this test stub + $this->attributes = $attributes; + } + + public function getAttribute(string $key): mixed + { + return 'foo_'.$this->attributes[$key]; + } +} + +class EloquentBuilderTestPluckDatesStub extends Model +{ + public function __construct(array $attributes) + { + // Don't call parent - directly set attributes for this test stub + $this->attributes = $attributes; + } + + protected function asDateTime(mixed $value): \Carbon\CarbonInterface + { + // Return a mock Carbon that stringifies to 'date_' prefix for test assertion + return Carbon::parse('date_'.$value); + } +} + +class EloquentBuilderTestModelParentStub extends Model +{ + public function foo() + { + return $this->belongsTo(EloquentBuilderTestModelCloseRelatedStub::class); + } + + public function address() + { + return $this->belongsTo(EloquentBuilderTestModelCloseRelatedStub::class, 'foo_id'); + } + + public function activeFoo() + { + return $this->belongsTo(EloquentBuilderTestModelCloseRelatedStub::class, 'foo_id')->where('active', true); + } + + public function roles() + { + return $this->belongsToMany( + EloquentBuilderTestModelFarRelatedStub::class, + 'user_role', + 'self_id', + 'related_id' + ); + } + + public function morph() + { + return $this->morphTo(); + } +} + +class EloquentBuilderTestModelCloseRelatedStub extends Model +{ + public function bar() + { + return $this->hasMany(EloquentBuilderTestModelFarRelatedStub::class); + } + + public function baz() + { + return $this->hasMany(EloquentBuilderTestModelFarRelatedStub::class); + } + + public function bam() + { + return $this->hasMany(EloquentBuilderTestModelOtherFarRelatedStub::class); + } +} + +class EloquentBuilderTestModelFarRelatedStub extends Model +{ + public function roles() + { + return $this->belongsToMany( + EloquentBuilderTestModelParentStub::class, + 'user_role', + 'related_id', + 'self_id', + ); + } + + public function baz() + { + return $this->belongsTo(EloquentBuilderTestModelCloseRelatedStub::class); + } +} + +class EloquentBuilderTestModelOtherFarRelatedStub extends Model +{ + public function roles() + { + return $this->belongsToMany( + EloquentBuilderTestModelParentStub::class, + 'user_role', + 'related_id', + 'self_id', + ); + } + + public function baz() + { + return $this->belongsTo(EloquentBuilderTestModelCloseRelatedStub::class); + } +} + +class EloquentBuilderTestModelSelfRelatedStub extends Model +{ + protected ?string $table = 'self_related_stubs'; + + public function parentFoo() + { + return $this->belongsTo(self::class, 'parent_id', 'id', 'parent'); + } + + public function childFoo() + { + return $this->hasOne(self::class, 'parent_id', 'id'); + } + + public function childFoos() + { + return $this->hasMany(self::class, 'parent_id', 'id', 'children'); + } + + public function parentBars() + { + return $this->belongsToMany(self::class, 'self_pivot', 'child_id', 'parent_id', 'parent_bars'); + } + + public function childBars() + { + return $this->belongsToMany(self::class, 'self_pivot', 'parent_id', 'child_id', 'child_bars'); + } + + public function bazes() + { + return $this->hasMany(EloquentBuilderTestModelFarRelatedStub::class, 'foreign_key', 'id', 'bar'); + } +} + +class EloquentBuilderTestStubWithoutTimestamp extends Model +{ + public const UPDATED_AT = null; + + protected ?string $table = 'table'; +} + +class EloquentBuilderTestStubStringPrimaryKey extends Model +{ + public bool $incrementing = false; + + protected ?string $table = 'foo_table'; + + protected string $keyType = 'string'; +} + +class EloquentBuilderTestWhereBelongsToStub extends Model +{ + protected array $fillable = [ + 'id', + 'parent_id', + ]; + + public function eloquentBuilderTestWhereBelongsToStub() + { + return $this->belongsTo(self::class, 'parent_id', 'id', 'parent'); + } + + public function parent() + { + return $this->belongsTo(self::class, 'parent_id', 'id', 'parent'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentCollectionQueueableTest.php b/tests/Database/Laravel/DatabaseEloquentCollectionQueueableTest.php new file mode 100644 index 000000000..5e9bfc8a5 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentCollectionQueueableTest.php @@ -0,0 +1,65 @@ +getQueueableIds(); + + $spy->shouldHaveReceived() + ->getQueueableId() + ->once(); + } + + public function testSerializesModelEntitiesById() + { + $spy = m::spy(Model::class); + + $c = new Collection([$spy]); + + $c->getQueueableIds(); + + $spy->shouldHaveReceived() + ->getQueueableId() + ->once(); + } + + /** + * @throws \Exception + */ + public function testJsonSerializationOfCollectionQueueableIdsWorks() + { + // When the ID of a Model is binary instead of int or string, the Collection + // serialization + JSON encoding breaks because of UTF-8 issues. Encoding + // of a QueueableCollection must favor QueueableEntity::queueableId(). + $mock = m::mock(Model::class, [ + 'getKey' => random_bytes(10), + 'getQueueableId' => 'mocked', + ]); + + $c = new Collection([$mock]); + + $payload = [ + 'ids' => $c->getQueueableIds(), + ]; + + $this->assertNotFalse( + json_encode($payload), + 'EloquentCollection is not using the QueueableEntity::getQueueableId() method.' + ); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentCollectionTest.php b/tests/Database/Laravel/DatabaseEloquentCollectionTest.php new file mode 100755 index 000000000..892392b1a --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentCollectionTest.php @@ -0,0 +1,904 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + }); + + $this->schema()->create('articles', function ($table) { + $table->increments('id'); + $table->integer('user_id'); + $table->string('title'); + }); + + $this->schema()->create('comments', function ($table) { + $table->increments('id'); + $table->integer('article_id'); + $table->string('content'); + }); + } + + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('comments'); + + parent::tearDown(); + } + + public function testAddingItemsToCollection() + { + $c = new Collection(['foo']); + $c->add('bar')->add('baz'); + $this->assertEquals(['foo', 'bar', 'baz'], $c->all()); + } + + public function testGettingMaxItemsFromCollection() + { + $c = new Collection([(object) ['foo' => 10], (object) ['foo' => 20]]); + $this->assertEquals(20, $c->max('foo')); + } + + public function testGettingMinItemsFromCollection() + { + $c = new Collection([(object) ['foo' => 10], (object) ['foo' => 20]]); + $this->assertEquals(10, $c->min('foo')); + } + + public function testContainsWithMultipleArguments() + { + $c = new Collection([['id' => 1], ['id' => 2]]); + + $this->assertTrue($c->contains('id', 1)); + $this->assertTrue($c->contains('id', '>=', 2)); + $this->assertFalse($c->contains('id', '>', 2)); + + $this->assertFalse($c->doesntContain('id', 1)); + $this->assertFalse($c->doesntContain('id', '>=', 2)); + $this->assertTrue($c->doesntContain('id', '>', 2)); + } + + public function testContainsIndicatesIfModelInArray() + { + $mockModel = m::mock(Model::class); + $mockModel->shouldReceive('is')->with($mockModel)->andReturn(true); + $mockModel->shouldReceive('is')->andReturn(false); + $mockModel2 = m::mock(Model::class); + $mockModel2->shouldReceive('is')->with($mockModel2)->andReturn(true); + $mockModel2->shouldReceive('is')->andReturn(false); + $mockModel3 = m::mock(Model::class); + $mockModel3->shouldReceive('is')->with($mockModel3)->andReturn(true); + $mockModel3->shouldReceive('is')->andReturn(false); + $c = new Collection([$mockModel, $mockModel2]); + + $this->assertTrue($c->contains($mockModel)); + $this->assertTrue($c->contains($mockModel2)); + $this->assertFalse($c->contains($mockModel3)); + + $this->assertFalse($c->doesntContain($mockModel)); + $this->assertFalse($c->doesntContain($mockModel2)); + $this->assertTrue($c->doesntContain($mockModel3)); + } + + public function testContainsIndicatesIfDifferentModelInArray() + { + $mockModelFoo = m::namedMock('Foo', Model::class); + $mockModelFoo->shouldReceive('is')->with($mockModelFoo)->andReturn(true); + $mockModelFoo->shouldReceive('is')->andReturn(false); + $mockModelBar = m::namedMock('Bar', Model::class); + $mockModelBar->shouldReceive('is')->with($mockModelBar)->andReturn(true); + $mockModelBar->shouldReceive('is')->andReturn(false); + $c = new Collection([$mockModelFoo]); + + $this->assertTrue($c->contains($mockModelFoo)); + $this->assertFalse($c->contains($mockModelBar)); + + $this->assertFalse($c->doesntContain($mockModelFoo)); + $this->assertTrue($c->doesntContain($mockModelBar)); + } + + public function testContainsIndicatesIfKeyedModelInArray() + { + $mockModel = m::mock(Model::class); + $mockModel->shouldReceive('getKey')->andReturn('1'); + $c = new Collection([$mockModel]); + $mockModel2 = m::mock(Model::class); + $mockModel2->shouldReceive('getKey')->andReturn('2'); + $c->add($mockModel2); + + $this->assertTrue($c->contains(1)); + $this->assertTrue($c->contains(2)); + $this->assertFalse($c->contains(3)); + + $this->assertFalse($c->doesntContain(1)); + $this->assertFalse($c->doesntContain(2)); + $this->assertTrue($c->doesntContain(3)); + } + + public function testContainsKeyAndValueIndicatesIfModelInArray() + { + $mockModel1 = m::mock(Model::class); + $mockModel1->shouldReceive('offsetExists')->with('name')->andReturn(true); + $mockModel1->shouldReceive('offsetGet')->with('name')->andReturn('Taylor'); + $mockModel2 = m::mock(Model::class); + $mockModel2->shouldReceive('offsetExists')->andReturn(true); + $mockModel2->shouldReceive('offsetGet')->with('name')->andReturn('Abigail'); + $c = new Collection([$mockModel1, $mockModel2]); + + $this->assertTrue($c->contains('name', 'Taylor')); + $this->assertTrue($c->contains('name', 'Abigail')); + $this->assertFalse($c->contains('name', 'Dayle')); + + $this->assertFalse($c->doesntContain('name', 'Taylor')); + $this->assertFalse($c->doesntContain('name', 'Abigail')); + $this->assertTrue($c->doesntContain('name', 'Dayle')); + } + + public function testContainsClosureIndicatesIfModelInArray() + { + $mockModel1 = m::mock(Model::class); + $mockModel1->shouldReceive('getKey')->andReturn(1); + $mockModel2 = m::mock(Model::class); + $mockModel2->shouldReceive('getKey')->andReturn(2); + $c = new Collection([$mockModel1, $mockModel2]); + + $this->assertTrue($c->contains(function ($model) { + return $model->getKey() < 2; + })); + $this->assertFalse($c->contains(function ($model) { + return $model->getKey() > 2; + })); + + $this->assertFalse($c->doesntContain(function ($model) { + return $model->getKey() < 2; + })); + $this->assertTrue($c->doesntContain(function ($model) { + return $model->getKey() > 2; + })); + } + + public function testFindMethodFindsModelById() + { + $mockModel = m::mock(Model::class); + $mockModel->shouldReceive('getKey')->andReturn(1); + $c = new Collection([$mockModel]); + + $this->assertSame($mockModel, $c->find(1)); + $this->assertSame('taylor', $c->find(2, 'taylor')); + } + + public function testFindMethodFindsManyModelsById() + { + $model1 = (new TestEloquentCollectionModel)->forceFill(['id' => 1]); + $model2 = (new TestEloquentCollectionModel)->forceFill(['id' => 2]); + $model3 = (new TestEloquentCollectionModel)->forceFill(['id' => 3]); + + $c = new Collection; + $this->assertInstanceOf(Collection::class, $c->find([])); + $this->assertCount(0, $c->find([1])); + + $c->push($model1); + $this->assertCount(1, $c->find([1])); + $this->assertEquals(1, $c->find([1])->first()->id); + $this->assertCount(0, $c->find([2])); + + $c->push($model2)->push($model3); + $this->assertCount(1, $c->find([2])); + $this->assertEquals(2, $c->find([2])->first()->id); + $this->assertCount(2, $c->find([2, 3, 4])); + $this->assertCount(2, $c->find(collect([2, 3, 4]))); + $this->assertEquals([2, 3], $c->find(collect([2, 3, 4]))->pluck('id')->all()); + $this->assertEquals([2, 3], $c->find([2, 3, 4])->pluck('id')->all()); + } + + public function testFindOrFailFindsModelById() + { + $mockModel = m::mock(Model::class); + $mockModel->shouldReceive('getKey')->andReturn(1); + $c = new Collection([$mockModel]); + + $this->assertSame($mockModel, $c->findOrFail(1)); + } + + public function testFindOrFailFindsManyModelsById() + { + $model1 = (new TestEloquentCollectionModel)->forceFill(['id' => 1]); + $model2 = (new TestEloquentCollectionModel)->forceFill(['id' => 2]); + + $c = new Collection; + $this->assertInstanceOf(Collection::class, $c->findOrFail([])); + $this->assertCount(0, $c->findOrFail([])); + + $c->push($model1); + $this->assertCount(1, $c->findOrFail([1])); + $this->assertEquals(1, $c->findOrFail([1])->first()->id); + + $c->push($model2); + $this->assertCount(2, $c->findOrFail([1, 2])); + + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\TestEloquentCollectionModel] 3'); + + $c->findOrFail([1, 2, 3]); + } + + public function testFindOrFailThrowsExceptionWithMessageWhenOtherModelsArePresent() + { + $model = (new TestEloquentCollectionModel)->forceFill(['id' => 1]); + + $c = new Collection([$model]); + + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\TestEloquentCollectionModel] 2'); + + $c->findOrFail(2); + } + + public function testFindOrFailThrowsExceptionWithoutMessageWhenOtherModelsAreNotPresent() + { + $c = new Collection(); + + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage(''); + + $c->findOrFail(1); + } + + public function testLoadMethodEagerLoadsGivenRelationships() + { + $c = $this->getMockBuilder(Collection::class)->onlyMethods(['first'])->setConstructorArgs([['foo']])->getMock(); + $mockItem = m::mock(stdClass::class); + $c->expects($this->once())->method('first')->willReturn($mockItem); + $mockItem->shouldReceive('newQueryWithoutRelationships')->once()->andReturn($mockItem); + $mockItem->shouldReceive('with')->with(['bar', 'baz'])->andReturn($mockItem); + $mockItem->shouldReceive('eagerLoadRelations')->once()->with(['foo'])->andReturn(['results']); + $c->load('bar', 'baz'); + + $this->assertEquals(['results'], $c->all()); + } + + public function testCollectionDictionaryReturnsModelKeys() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $three = m::mock(Model::class); + $three->shouldReceive('getKey')->andReturn(3); + + $c = new Collection([$one, $two, $three]); + + $this->assertEquals([1, 2, 3], $c->modelKeys()); + } + + public function testCollectionMergesWithGivenCollection() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $three = m::mock(Model::class); + $three->shouldReceive('getKey')->andReturn(3); + + $c1 = new Collection([$one, $two]); + $c2 = new Collection([$two, $three]); + + $this->assertEquals(new Collection([$one, $two, $three]), $c1->merge($c2)); + } + + public function testMap() + { + $one = m::mock(Model::class); + $two = m::mock(Model::class); + + $c = new Collection([$one, $two]); + + $cAfterMap = $c->map(function ($item) { + return $item; + }); + + $this->assertEquals($c->all(), $cAfterMap->all()); + $this->assertInstanceOf(Collection::class, $cAfterMap); + } + + public function testMappingToNonModelsReturnsABaseCollection() + { + $one = m::mock(Model::class); + $two = m::mock(Model::class); + + $c = (new Collection([$one, $two]))->map(function ($item) { + return 'not-a-model'; + }); + + $this->assertEquals(BaseCollection::class, get_class($c)); + } + + public function testMapWithKeys() + { + $one = m::mock(Model::class); + $two = m::mock(Model::class); + + $c = new Collection([$one, $two]); + + $key = 0; + $cAfterMap = $c->mapWithKeys(function ($item) use (&$key) { + return [$key++ => $item]; + }); + + $this->assertEquals($c->all(), $cAfterMap->all()); + $this->assertInstanceOf(Collection::class, $cAfterMap); + } + + public function testMapWithKeysToNonModelsReturnsABaseCollection() + { + $one = m::mock(Model::class); + $two = m::mock(Model::class); + + $key = 0; + $c = (new Collection([$one, $two]))->mapWithKeys(function ($item) use (&$key) { + return [$key++ => 'not-a-model']; + }); + + $this->assertEquals(BaseCollection::class, get_class($c)); + } + + public function testCollectionDiffsWithGivenCollection() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $three = m::mock(Model::class); + $three->shouldReceive('getKey')->andReturn(3); + + $c1 = new Collection([$one, $two]); + $c2 = new Collection([$two, $three]); + + $this->assertEquals(new Collection([$one]), $c1->diff($c2)); + } + + public function testCollectionReturnsDuplicateBasedOnlyOnKeys() + { + $one = new TestEloquentCollectionModel; + $two = new TestEloquentCollectionModel; + $three = new TestEloquentCollectionModel; + $four = new TestEloquentCollectionModel; + $one->id = 1; + $one->someAttribute = '1'; + $two->id = 1; + $two->someAttribute = '2'; + $three->id = 1; + $three->someAttribute = '3'; + $four->id = 2; + $four->someAttribute = '4'; + + $duplicates = Collection::make([$one, $two, $three, $four])->duplicates()->all(); + $this->assertSame([1 => $two, 2 => $three], $duplicates); + + $duplicates = Collection::make([$one, $two, $three, $four])->duplicatesStrict()->all(); + $this->assertSame([1 => $two, 2 => $three], $duplicates); + } + + public function testCollectionIntersectWithNull() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $three = m::mock(Model::class); + $three->shouldReceive('getKey')->andReturn(3); + + $c1 = new Collection([$one, $two, $three]); + + $this->assertEquals([], $c1->intersect(null)->all()); + } + + public function testCollectionIntersectsWithGivenCollection() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $three = m::mock(Model::class); + $three->shouldReceive('getKey')->andReturn(3); + + $c1 = new Collection([$one, $two]); + $c2 = new Collection([$two, $three]); + + $this->assertEquals(new Collection([$two]), $c1->intersect($c2)); + } + + public function testCollectionReturnsUniqueItems() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $c = new Collection([$one, $two, $two]); + + $this->assertEquals(new Collection([$one, $two]), $c->unique()); + } + + public function testCollectionReturnsUniqueStrictBasedOnKeysOnly() + { + $one = new TestEloquentCollectionModel; + $two = new TestEloquentCollectionModel; + $three = new TestEloquentCollectionModel; + $four = new TestEloquentCollectionModel; + $one->id = 1; + $one->someAttribute = '1'; + $two->id = 1; + $two->someAttribute = '2'; + $three->id = 1; + $three->someAttribute = '3'; + $four->id = 2; + $four->someAttribute = '4'; + + $uniques = Collection::make([$one, $two, $three, $four])->unique()->all(); + $this->assertSame([$three, $four], $uniques); + + $uniques = Collection::make([$one, $two, $three, $four])->unique(null, true)->all(); + $this->assertSame([$three, $four], $uniques); + } + + public function testOnlyReturnsCollectionWithGivenModelKeys() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $three = m::mock(Model::class); + $three->shouldReceive('getKey')->andReturn(3); + + $c = new Collection([$one, $two, $three]); + + $this->assertEquals($c, $c->only(null)); + $this->assertEquals(new Collection([$one]), $c->only(1)); + $this->assertEquals(new Collection([$two, $three]), $c->only([2, 3])); + } + + public function testExceptReturnsCollectionWithoutGivenModelKeys() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $three = m::mock(Model::class); + $three->shouldReceive('getKey')->andReturn(3); + + $c = new Collection([$one, $two, $three]); + + $this->assertEquals($c, $c->except(null)); + $this->assertEquals(new Collection([$one, $three]), $c->except(2)); + $this->assertEquals(new Collection([$one]), $c->except([2, 3])); + } + + public function testMakeHiddenAddsHiddenOnEntireCollection() + { + $c = new Collection([new TestEloquentCollectionModel]); + $c = $c->makeHidden(['visible']); + + $this->assertEquals(['hidden', 'visible'], $c[0]->getHidden()); + } + + public function testMakeVisibleRemovesHiddenFromEntireCollection() + { + $c = new Collection([new TestEloquentCollectionModel]); + $c = $c->makeVisible(['hidden']); + + $this->assertEquals([], $c[0]->getHidden()); + } + + public function testMergeHiddenAddsHiddenOnEntireCollection() + { + $c = new Collection([new TestEloquentCollectionModel]); + $c = $c->mergeHidden(['merged']); + + $this->assertEquals(['hidden', 'merged'], $c[0]->getHidden()); + } + + public function testMergeVisibleRemovesHiddenFromEntireCollection() + { + $c = new Collection([new TestEloquentCollectionModel]); + $c = $c->mergeVisible(['merged']); + + $this->assertEquals(['visible', 'merged'], $c[0]->getVisible()); + } + + public function testSetVisibleReplacesVisibleOnEntireCollection() + { + $c = new Collection([new TestEloquentCollectionModel]); + $c = $c->setVisible(['hidden']); + + $this->assertEquals(['hidden'], $c[0]->getVisible()); + } + + public function testSetHiddenReplacesHiddenOnEntireCollection() + { + $c = new Collection([new TestEloquentCollectionModel]); + $c = $c->setHidden(['visible']); + + $this->assertEquals(['visible'], $c[0]->getHidden()); + } + + public function testAppendsAddsTestOnEntireCollection() + { + $c = new Collection([new TestEloquentCollectionModel]); + $c = $c->makeVisible('test'); + $c = $c->append('test'); + + $this->assertEquals(['test' => 'test'], $c[0]->toArray()); + } + + public function testSetAppendsSetsAppendedPropertiesOnEntireCollection() + { + $c = new Collection([new EloquentAppendingTestUserModel]); + $c->setAppends(['other_appended_field']); + + $this->assertEquals( + [['other_appended_field' => 'bye']], + $c->toArray() + ); + } + + public function testWithoutAppendsRemovesAppendsOnEntireCollection() + { + $this->seedData(); + $c = EloquentAppendingTestUserModel::query()->get(); + $this->assertEquals('hello', $c->toArray()[0]['appended_field']); + + $c = $c->withoutAppends(); + $this->assertArrayNotHasKey('appended_field', $c->toArray()[0]); + } + + public function testNonModelRelatedMethods() + { + $a = new Collection([['foo' => 'bar'], ['foo' => 'baz']]); + $b = new Collection(['a', 'b', 'c']); + $this->assertEquals(BaseCollection::class, get_class($a->pluck('foo'))); + $this->assertEquals(BaseCollection::class, get_class($a->keys())); + $this->assertEquals(BaseCollection::class, get_class($a->collapse())); + $this->assertEquals(BaseCollection::class, get_class($a->flatten())); + $this->assertEquals(BaseCollection::class, get_class($a->zip(['a', 'b'], ['c', 'd']))); + $this->assertEquals(BaseCollection::class, get_class($a->countBy('foo'))); + $this->assertEquals(BaseCollection::class, get_class($b->flip())); + $this->assertEquals(BaseCollection::class, get_class($a->partition('foo', '=', 'bar'))); + $this->assertEquals(BaseCollection::class, get_class($a->partition('foo', 'bar'))); + } + + public function testMakeVisibleRemovesHiddenAndIncludesVisible() + { + $c = new Collection([new TestEloquentCollectionModel]); + $c = $c->makeVisible('hidden'); + + $this->assertEquals([], $c[0]->getHidden()); + $this->assertEquals(['visible', 'hidden'], $c[0]->getVisible()); + } + + public function testMultiply() + { + $a = new TestEloquentCollectionModel(); + $b = new TestEloquentCollectionModel(); + + $c = new Collection([$a, $b]); + + $this->assertEquals([], $c->multiply(-1)->all()); + $this->assertEquals([], $c->multiply(0)->all()); + + $this->assertEquals([$a, $b], $c->multiply(1)->all()); + + $this->assertEquals([$a, $b, $a, $b, $a, $b], $c->multiply(3)->all()); + } + + public function testQueueableCollectionImplementation() + { + $c = new Collection([new TestEloquentCollectionModel, new TestEloquentCollectionModel]); + $this->assertEquals(TestEloquentCollectionModel::class, $c->getQueueableClass()); + } + + public function testQueueableCollectionImplementationThrowsExceptionOnMultipleModelTypes() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Queueing collections with multiple model types is not supported.'); + + $c = new Collection([new TestEloquentCollectionModel, (object) ['id' => 'something']]); + $c->getQueueableClass(); + } + + public function testQueueableRelationshipsReturnsOnlyRelationsCommonToAllModels() + { + // This is needed to prevent loading non-existing relationships on polymorphic model collections (#26126) + $c = new Collection([ + new class + { + public function getQueueableRelations() + { + return ['user']; + } + }, + new class + { + public function getQueueableRelations() + { + return ['user', 'comments']; + } + }, + ]); + + $this->assertEquals(['user'], $c->getQueueableRelations()); + } + + public function testQueueableRelationshipsIgnoreCollectionKeys() + { + $c = new Collection([ + 'foo' => new class + { + public function getQueueableRelations() + { + return []; + } + }, + 'bar' => new class + { + public function getQueueableRelations() + { + return []; + } + }, + ]); + + $this->assertEquals([], $c->getQueueableRelations()); + } + + public function testEmptyCollectionStayEmptyOnFresh() + { + $c = new Collection; + $this->assertEquals($c, $c->fresh()); + } + + public function testCanConvertCollectionOfModelsToEloquentQueryBuilder() + { + $one = m::mock(Model::class); + $one->shouldReceive('getKey')->andReturn(1); + + $two = m::mock(Model::class); + $two->shouldReceive('getKey')->andReturn(2); + + $c = new Collection([$one, $two]); + + $mocBuilder = m::mock(Builder::class); + $one->shouldReceive('newModelQuery')->once()->andReturn($mocBuilder); + $mocBuilder->shouldReceive('whereKey')->once()->with($c->modelKeys())->andReturn($mocBuilder); + $this->assertInstanceOf(Builder::class, $c->toQuery()); + } + + public function testConvertingEmptyCollectionToQueryThrowsException() + { + $this->expectException(LogicException::class); + + $c = new Collection; + $c->toQuery(); + } + + public function testLoadExistsShouldCastBool() + { + $this->seedData(); + $user = EloquentTestUserModel::with('articles')->first(); + $user->articles->loadExists('comments'); + $commentsExists = $user->articles->pluck('comments_exists')->toArray(); + + $this->assertContainsOnly('bool', $commentsExists); + } + + public function testWithNonScalarKey() + { + $fooKey = new EloquentTestKey('foo'); + $foo = m::mock(Model::class); + $foo->shouldReceive('getKey')->andReturn($fooKey); + + $barKey = new EloquentTestKey('bar'); + $bar = m::mock(Model::class); + $bar->shouldReceive('getKey')->andReturn($barKey); + + $collection = new Collection([$foo, $bar]); + + $this->assertCount(1, $collection->only([$fooKey])); + $this->assertSame($foo, $collection->only($fooKey)->first()); + + $this->assertCount(1, $collection->except([$fooKey])); + $this->assertSame($bar, $collection->except($fooKey)->first()); + } + + public function testPluck() + { + $model1 = (new TestEloquentCollectionModel)->forceFill(['id' => 1, 'name' => 'John', 'country' => 'US']); + $model2 = (new TestEloquentCollectionModel)->forceFill(['id' => 2, 'name' => 'Jane', 'country' => 'NL']); + $model3 = (new TestEloquentCollectionModel)->forceFill(['id' => 3, 'name' => 'Taylor', 'country' => 'US']); + + $c = new Collection; + + $c->push($model1)->push($model2)->push($model3); + + $this->assertInstanceOf(BaseCollection::class, $c->pluck('id')); + $this->assertEquals([1, 2, 3], $c->pluck('id')->all()); + + $this->assertInstanceOf(BaseCollection::class, $c->pluck('id', 'id')); + $this->assertEquals([1 => 1, 2 => 2, 3 => 3], $c->pluck('id', 'id')->all()); + $this->assertInstanceOf(BaseCollection::class, $c->pluck('test')); + + $this->assertEquals(['John (US)', 'Jane (NL)', 'Taylor (US)'], $c->pluck(fn (TestEloquentCollectionModel $model) => "{$model->name} ({$model->country})")->all()); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = EloquentTestUserModel::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + + EloquentTestArticleModel::query()->insert([ + ['user_id' => 1, 'title' => 'Another title'], + ['user_id' => 1, 'title' => 'Another title'], + ['user_id' => 1, 'title' => 'Another title'], + ]); + + EloquentTestCommentModel::query()->insert([ + ['article_id' => 1, 'content' => 'Another comment'], + ['article_id' => 2, 'content' => 'Another comment'], + ]); + } + + /** + * Get a database connection instance. + * + * @return \Hypervel\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Hypervel\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class TestEloquentCollectionModel extends Model +{ + protected array $visible = ['visible']; + protected array $hidden = ['hidden']; + + public function getTestAttribute(): string + { + return 'test'; + } +} + +class EloquentTestUserModel extends Model +{ + protected ?string $table = 'users'; + protected array $guarded = []; + public bool $timestamps = false; + + public function articles() + { + return $this->hasMany(EloquentTestArticleModel::class, 'user_id'); + } +} + +class EloquentTestArticleModel extends Model +{ + protected ?string $table = 'articles'; + protected array $guarded = []; + public bool $timestamps = false; + + public function comments() + { + return $this->hasMany(EloquentTestCommentModel::class, 'article_id'); + } +} + +class EloquentTestCommentModel extends Model +{ + protected ?string $table = 'comments'; + protected array $guarded = []; + public bool $timestamps = false; +} + +class EloquentTestKey +{ + public function __construct(private readonly string $key) + { + } + + public function __toString(): string + { + return $this->key; + } +} + +class EloquentAppendingTestUserModel extends Model +{ + protected ?string $table = 'users'; + protected array $guarded = []; + public bool $timestamps = false; + protected array $appends = ['appended_field']; + + public function getAppendedFieldAttribute(): string + { + return 'hello'; + } + + public function getOtherAppendedFieldAttribute(): string + { + return 'bye'; + } + + public function articles() + { + return $this->hasMany(EloquentTestArticleModel::class, 'user_id'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentDynamicRelationsTest.php b/tests/Database/Laravel/DatabaseEloquentDynamicRelationsTest.php new file mode 100644 index 000000000..38e0c3a2b --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentDynamicRelationsTest.php @@ -0,0 +1,141 @@ + new FakeHasManyRel); + $model = new DynamicRelationModel; + $this->assertEquals(['many' => 'related'], $model->dynamicRel_2); + $this->assertEquals(['many' => 'related'], $model->getRelationValue('dynamicRel_2')); + } + + public function testBasicDynamicRelationsOverride() + { + // Dynamic Relations can override each other. + DynamicRelationModel::resolveRelationUsing('dynamicRelConflict', fn ($m) => $m->hasOne(Related::class)); + DynamicRelationModel::resolveRelationUsing('dynamicRelConflict', fn (DynamicRelationModel $m) => new FakeHasManyRel); + + $model = new DynamicRelationModel; + $this->assertInstanceOf(HasMany::class, $model->dynamicRelConflict()); + $this->assertEquals(['many' => 'related'], $model->dynamicRelConflict); + $this->assertEquals(['many' => 'related'], $model->getRelationValue('dynamicRelConflict')); + $this->assertTrue($model->isRelation('dynamicRelConflict')); + } + + public function testInharitedDynamicRelations() + { + DynamicRelationModel::resolveRelationUsing('inheritedDynamicRel', fn () => new FakeHasManyRel); + $model = new DynamicRelationModel; + $model2 = new DynamicRelationModel2; + $model4 = new DynamicRelationModel4; + $this->assertTrue($model->isRelation('inheritedDynamicRel')); + $this->assertTrue($model4->isRelation('inheritedDynamicRel')); + $this->assertFalse($model2->isRelation('inheritedDynamicRel')); + $this->assertEquals($model->inheritedDynamicRel(), $model4->inheritedDynamicRel()); + $this->assertEquals($model->inheritedDynamicRel, $model4->inheritedDynamicRel); + } + + public function testInheritedDynamicRelationsOverride() + { + // Inherited Dynamic Relations can be overridden + DynamicRelationModel::resolveRelationUsing('dynamicRelConflict', fn ($m) => $m->hasOne(Related::class)); + $model = new DynamicRelationModel; + $model4 = new DynamicRelationModel4; + $this->assertInstanceOf(HasOne::class, $model->dynamicRelConflict()); + $this->assertInstanceOf(HasOne::class, $model4->dynamicRelConflict()); + DynamicRelationModel4::resolveRelationUsing('dynamicRelConflict', fn ($m) => $m->hasMany(Related::class)); + $this->assertInstanceOf(HasOne::class, $model->dynamicRelConflict()); + $this->assertInstanceOf(HasMany::class, $model4->dynamicRelConflict()); + } + + public function testDynamicRelationsCanNotHaveTheSameNameAsNormalRelations() + { + $model = new DynamicRelationModel; + + // Dynamic relations can not override hard-coded methods. + DynamicRelationModel::resolveRelationUsing('hardCodedRelation', fn ($m) => $m->hasOne(Related::class)); + $this->assertInstanceOf(HasMany::class, $model->hardCodedRelation()); + $this->assertEquals(['many' => 'related'], $model->hardCodedRelation); + $this->assertEquals(['many' => 'related'], $model->getRelationValue('hardCodedRelation')); + $this->assertTrue($model->isRelation('hardCodedRelation')); + } + + public function testRelationResolvers() + { + $model1 = new DynamicRelationModel; + $model3 = new DynamicRelationModel3; + + // Same dynamic methods with the same name on two models do not conflict or override. + DynamicRelationModel::resolveRelationUsing('dynamicRel', fn ($m) => $m->hasOne(Related::class)); + DynamicRelationModel3::resolveRelationUsing('dynamicRel', fn (DynamicRelationModel3 $m) => $m->hasMany(Related::class)); + $this->assertInstanceOf(HasOne::class, $model1->dynamicRel()); + $this->assertInstanceOf(HasMany::class, $model3->dynamicRel()); + $this->assertTrue($model1->isRelation('dynamicRel')); + $this->assertTrue($model3->isRelation('dynamicRel')); + } +} + +class DynamicRelationModel extends Model +{ + public function hardCodedRelation() + { + return new FakeHasManyRel(); + } +} + +class DynamicRelationModel2 extends Model +{ + public function getResults(): void + { + // + } + + public function newQuery(): Builder + { + $query = new class extends Query + { + public function __construct() + { + // + } + }; + + return (new Builder($query))->setModel($this); + } +} + +class DynamicRelationModel3 extends Model +{ + // +} + +class DynamicRelationModel4 extends DynamicRelationModel +{ + // +} + +class FakeHasManyRel extends HasMany +{ + public function __construct() + { + // + } + + public function getResults() + { + return ['many' => 'related']; + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentFactoryTest.php b/tests/Database/Laravel/DatabaseEloquentFactoryTest.php new file mode 100644 index 000000000..96d14ea47 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentFactoryTest.php @@ -0,0 +1,1288 @@ +markTestSkipped( + 'Requires Laravel container port - uses Container::setInstance(null) and other Laravel-specific container behaviors' + ); + + $container = Container::getInstance(); + $container->singleton(Generator::class, function ($app, $parameters) { + return \Faker\Factory::create('en_US'); + }); + $container->instance(Application::class, $app = m::mock(Application::class)); + $app->shouldReceive('getNamespace')->andReturn('App\\'); + + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + Factory::expandRelationshipsByDefault(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->string('options')->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('posts', function ($table) { + $table->increments('id'); + $table->foreignId('user_id'); + $table->string('title'); + $table->softDeletes(); + $table->timestamps(); + }); + + $this->schema()->create('comments', function ($table) { + $table->increments('id'); + $table->foreignId('commentable_id'); + $table->string('commentable_type'); + $table->foreignId('user_id'); + $table->string('body'); + $table->softDeletes(); + $table->timestamps(); + }); + + $this->schema()->create('roles', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema()->create('role_user', function ($table) { + $table->foreignId('role_id'); + $table->foreignId('user_id'); + $table->string('admin')->default('N'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + + Container::setInstance(null); + + parent::tearDown(); + } + + public function test_basic_model_can_be_created() + { + $user = FactoryTestUserFactory::new()->create(); + $this->assertInstanceOf(Eloquent::class, $user); + + $user = FactoryTestUserFactory::new()->createOne(); + $this->assertInstanceOf(Eloquent::class, $user); + + $user = FactoryTestUserFactory::new()->create(['name' => 'Taylor Otwell']); + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + + $user = FactoryTestUserFactory::new()->set('name', 'Taylor Otwell')->create(); + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + + $users = FactoryTestUserFactory::new()->createMany([ + ['name' => 'Taylor Otwell'], + ['name' => 'Jeffrey Way'], + ]); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + + $users = FactoryTestUserFactory::new()->createMany(2); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + $this->assertInstanceOf(FactoryTestUser::class, $users->first()); + + $users = FactoryTestUserFactory::times(2)->createMany(); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + $this->assertInstanceOf(FactoryTestUser::class, $users->first()); + + $users = FactoryTestUserFactory::times(2)->createMany(); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + $this->assertInstanceOf(FactoryTestUser::class, $users->first()); + + $users = FactoryTestUserFactory::times(3)->createMany([ + ['name' => 'Taylor Otwell'], + ['name' => 'Jeffrey Way'], + ]); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + $this->assertInstanceOf(FactoryTestUser::class, $users->first()); + + $users = FactoryTestUserFactory::new()->createMany(); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(1, $users); + $this->assertInstanceOf(FactoryTestUser::class, $users->first()); + + $users = FactoryTestUserFactory::times(10)->create(); + $this->assertCount(10, $users); + } + + public function test_expanded_closure_attributes_are_resolved_and_passed_to_closures() + { + $user = FactoryTestUserFactory::new()->create([ + 'name' => function () { + return 'taylor'; + }, + 'options' => function ($attributes) { + return $attributes['name'].'-options'; + }, + ]); + + $this->assertSame('taylor-options', $user->options); + } + + public function test_expanded_closure_attribute_returning_a_factory_is_resolved() + { + $post = FactoryTestPostFactory::new()->create([ + 'title' => 'post', + 'user_id' => fn ($attributes) => FactoryTestUserFactory::new([ + 'options' => $attributes['title'].'-options', + ]), + ]); + + $this->assertEquals('post-options', $post->user->options); + } + + public function test_make_creates_unpersisted_model_instance() + { + $user = FactoryTestUserFactory::new()->makeOne(); + $this->assertInstanceOf(Eloquent::class, $user); + + $user = FactoryTestUserFactory::new()->make(['name' => 'Taylor Otwell']); + + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + $this->assertCount(0, FactoryTestUser::all()); + } + + public function test_basic_model_attributes_can_be_created() + { + $user = FactoryTestUserFactory::new()->raw(); + $this->assertIsArray($user); + + $user = FactoryTestUserFactory::new()->raw(['name' => 'Taylor Otwell']); + $this->assertIsArray($user); + $this->assertSame('Taylor Otwell', $user['name']); + } + + public function test_expanded_model_attributes_can_be_created() + { + $post = FactoryTestPostFactory::new()->raw(); + $this->assertIsArray($post); + + $post = FactoryTestPostFactory::new()->raw(['title' => 'Test Title']); + $this->assertIsArray($post); + $this->assertIsInt($post['user_id']); + $this->assertSame('Test Title', $post['title']); + } + + public function test_lazy_model_attributes_can_be_created() + { + $userFunction = FactoryTestUserFactory::new()->lazy(); + $this->assertIsCallable($userFunction); + $this->assertInstanceOf(Eloquent::class, $userFunction()); + + $userFunction = FactoryTestUserFactory::new()->lazy(['name' => 'Taylor Otwell']); + $this->assertIsCallable($userFunction); + + $user = $userFunction(); + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + } + + public function test_multiple_model_attributes_can_be_created() + { + $posts = FactoryTestPostFactory::times(10)->raw(); + $this->assertIsArray($posts); + + $this->assertCount(10, $posts); + } + + public function test_after_creating_and_making_callbacks_are_called() + { + $user = FactoryTestUserFactory::new() + ->afterMaking(function ($user) { + $_SERVER['__test.user.making'] = $user; + }) + ->afterCreating(function ($user) { + $_SERVER['__test.user.creating'] = $user; + }) + ->create(); + + $this->assertSame($user, $_SERVER['__test.user.making']); + $this->assertSame($user, $_SERVER['__test.user.creating']); + + unset($_SERVER['__test.user.making'], $_SERVER['__test.user.creating']); + } + + public function test_has_many_relationship() + { + $users = FactoryTestUserFactory::times(10) + ->has( + FactoryTestPostFactory::times(3) + ->state(function ($attributes, $user) { + // Test parent is passed to child state mutations... + $_SERVER['__test.post.state-user'] = $user; + + return []; + }) + // Test parents passed to callback... + ->afterCreating(function ($post, $user) { + $_SERVER['__test.post.creating-post'] = $post; + $_SERVER['__test.post.creating-user'] = $user; + }), + 'posts' + ) + ->create(); + + $this->assertCount(10, FactoryTestUser::all()); + $this->assertCount(30, FactoryTestPost::all()); + $this->assertCount(3, FactoryTestUser::latest()->first()->posts); + + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.post.creating-post']); + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.post.creating-user']); + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.post.state-user']); + + unset($_SERVER['__test.post.creating-post'], $_SERVER['__test.post.creating-user'], $_SERVER['__test.post.state-user']); + } + + public function test_belongs_to_relationship() + { + $posts = FactoryTestPostFactory::times(3) + ->for(FactoryTestUserFactory::new(['name' => 'Taylor Otwell']), 'user') + ->create(); + + $this->assertCount(3, $posts->filter(function ($post) { + return $post->user->name === 'Taylor Otwell'; + })); + + $this->assertCount(1, FactoryTestUser::all()); + $this->assertCount(3, FactoryTestPost::all()); + } + + public function test_belongs_to_relationship_with_existing_model_instance() + { + $user = FactoryTestUserFactory::new(['name' => 'Taylor Otwell'])->create(); + $posts = FactoryTestPostFactory::times(3) + ->for($user, 'user') + ->create(); + + $this->assertCount(3, $posts->filter(function ($post) use ($user) { + return $post->user->is($user); + })); + + $this->assertCount(1, FactoryTestUser::all()); + $this->assertCount(3, FactoryTestPost::all()); + } + + public function test_belongs_to_relationship_with_existing_model_instance_with_relationship_name_implied_from_model() + { + $user = FactoryTestUserFactory::new(['name' => 'Taylor Otwell'])->create(); + $posts = FactoryTestPostFactory::times(3) + ->for($user) + ->create(); + + $this->assertCount(3, $posts->filter(function ($post) use ($user) { + return $post->factoryTestUser->is($user); + })); + + $this->assertCount(1, FactoryTestUser::all()); + $this->assertCount(3, FactoryTestPost::all()); + } + + public function test_morph_to_relationship() + { + $posts = FactoryTestCommentFactory::times(3) + ->for(FactoryTestPostFactory::new(['title' => 'Test Title']), 'commentable') + ->create(); + + $this->assertSame('Test Title', FactoryTestPost::first()->title); + $this->assertCount(3, FactoryTestPost::first()->comments); + + $this->assertCount(1, FactoryTestPost::all()); + $this->assertCount(3, FactoryTestComment::all()); + } + + public function test_morph_to_relationship_with_existing_model_instance() + { + $post = FactoryTestPostFactory::new(['title' => 'Test Title'])->create(); + $posts = FactoryTestCommentFactory::times(3) + ->for($post, 'commentable') + ->create(); + + $this->assertSame('Test Title', FactoryTestPost::first()->title); + $this->assertCount(3, FactoryTestPost::first()->comments); + + $this->assertCount(1, FactoryTestPost::all()); + $this->assertCount(3, FactoryTestComment::all()); + } + + public function test_belongs_to_many_relationship() + { + $users = FactoryTestUserFactory::times(3) + ->hasAttached( + FactoryTestRoleFactory::times(3)->afterCreating(function ($role, $user) { + $_SERVER['__test.role.creating-role'] = $role; + $_SERVER['__test.role.creating-user'] = $user; + }), + ['admin' => 'Y'], + 'roles' + ) + ->create(); + + $this->assertCount(9, FactoryTestRole::all()); + + $user = FactoryTestUser::latest()->first(); + + $this->assertCount(3, $user->roles); + $this->assertSame('Y', $user->roles->first()->pivot->admin); + + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.role.creating-role']); + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.role.creating-user']); + + unset($_SERVER['__test.role.creating-role'], $_SERVER['__test.role.creating-user']); + } + + public function test_belongs_to_many_relationship_related_models_set_on_instance_when_touching_owner() + { + $user = FactoryTestUserFactory::new()->create(); + $role = FactoryTestRoleFactory::new()->hasAttached($user, [], 'users')->create(); + + $this->assertCount(1, $role->users); + } + + public function test_relation_can_be_loaded_before_model_is_created() + { + $user = FactoryTestUserFactory::new(['name' => 'Taylor Otwell'])->createOne(); + + $post = FactoryTestPostFactory::new() + ->for($user, 'user') + ->afterMaking(function (FactoryTestPost $post) { + $post->load('user'); + }) + ->createOne(); + + $this->assertTrue($post->relationLoaded('user')); + $this->assertTrue($post->user->is($user)); + + $this->assertCount(1, FactoryTestUser::all()); + $this->assertCount(1, FactoryTestPost::all()); + } + + public function test_belongs_to_many_relationship_with_existing_model_instances() + { + $roles = FactoryTestRoleFactory::times(3) + ->afterCreating(function ($role) { + $_SERVER['__test.role.creating-role'] = $role; + }) + ->create(); + FactoryTestUserFactory::times(3) + ->hasAttached($roles, ['admin' => 'Y'], 'roles') + ->create(); + + $this->assertCount(3, FactoryTestRole::all()); + + $user = FactoryTestUser::latest()->first(); + + $this->assertCount(3, $user->roles); + $this->assertSame('Y', $user->roles->first()->pivot->admin); + + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.role.creating-role']); + + unset($_SERVER['__test.role.creating-role']); + } + + public function test_belongs_to_many_relationship_with_existing_model_instances_using_array() + { + $roles = FactoryTestRoleFactory::times(3) + ->afterCreating(function ($role) { + $_SERVER['__test.role.creating-role'] = $role; + }) + ->create(); + FactoryTestUserFactory::times(3) + ->hasAttached($roles->toArray(), ['admin' => 'Y'], 'roles') + ->create(); + + $this->assertCount(3, FactoryTestRole::all()); + + $user = FactoryTestUser::latest()->first(); + + $this->assertCount(3, $user->roles); + $this->assertSame('Y', $user->roles->first()->pivot->admin); + + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.role.creating-role']); + + unset($_SERVER['__test.role.creating-role']); + } + + public function test_belongs_to_many_relationship_with_existing_model_instances_with_relationship_name_implied_from_model() + { + $roles = FactoryTestRoleFactory::times(3) + ->afterCreating(function ($role) { + $_SERVER['__test.role.creating-role'] = $role; + }) + ->create(); + FactoryTestUserFactory::times(3) + ->hasAttached($roles, ['admin' => 'Y']) + ->create(); + + $this->assertCount(3, FactoryTestRole::all()); + + $user = FactoryTestUser::latest()->first(); + + $this->assertCount(3, $user->factoryTestRoles); + $this->assertSame('Y', $user->factoryTestRoles->first()->pivot->admin); + + $this->assertInstanceOf(Eloquent::class, $_SERVER['__test.role.creating-role']); + + unset($_SERVER['__test.role.creating-role']); + } + + public function test_sequences() + { + $users = FactoryTestUserFactory::times(2)->sequence( + ['name' => 'Taylor Otwell'], + ['name' => 'Abigail Otwell'], + )->create(); + + $this->assertSame('Taylor Otwell', $users[0]->name); + $this->assertSame('Abigail Otwell', $users[1]->name); + + $user = FactoryTestUserFactory::new() + ->hasAttached( + FactoryTestRoleFactory::times(4), + new Sequence(['admin' => 'Y'], ['admin' => 'N']), + 'roles' + ) + ->create(); + + $this->assertCount(4, $user->roles); + + $this->assertCount(2, $user->roles->filter(function ($role) { + return $role->pivot->admin === 'Y'; + })); + + $this->assertCount(2, $user->roles->filter(function ($role) { + return $role->pivot->admin === 'N'; + })); + + $users = FactoryTestUserFactory::times(2)->sequence(function ($sequence) { + return ['name' => 'index: '.$sequence->index]; + })->create(); + + $this->assertSame('index: 0', $users[0]->name); + $this->assertSame('index: 1', $users[1]->name); + } + + public function test_counted_sequence() + { + $factory = FactoryTestUserFactory::new()->forEachSequence( + ['name' => 'Taylor Otwell'], + ['name' => 'Abigail Otwell'], + ['name' => 'Dayle Rees'] + ); + + $class = new ReflectionClass($factory); + $prop = $class->getProperty('count'); + $value = $prop->getValue($factory); + + $this->assertSame(3, $value); + } + + public function test_sequence_with_has_many_relationship() + { + $users = FactoryTestUserFactory::times(2) + ->sequence( + ['name' => 'Abigail Otwell'], + ['name' => 'Taylor Otwell'], + ) + ->has( + FactoryTestPostFactory::times(3) + ->state(['title' => 'Post']) + ->sequence(function ($sequence, $attributes, $user) { + return ['title' => $user->name.' '.$attributes['title'].' '.($sequence->index % 3 + 1)]; + }), + 'posts' + ) + ->create(); + + $this->assertCount(2, FactoryTestUser::all()); + $this->assertCount(6, FactoryTestPost::all()); + $this->assertCount(3, FactoryTestUser::latest()->first()->posts); + $this->assertEquals( + FactoryTestPost::orderBy('title')->pluck('title')->all(), + [ + 'Abigail Otwell Post 1', + 'Abigail Otwell Post 2', + 'Abigail Otwell Post 3', + 'Taylor Otwell Post 1', + 'Taylor Otwell Post 2', + 'Taylor Otwell Post 3', + ] + ); + } + + public function test_cross_join_sequences() + { + $assert = function ($users) { + $assertions = [ + ['first_name' => 'Thomas', 'last_name' => 'Anderson'], + ['first_name' => 'Thomas', 'last_name' => 'Smith'], + ['first_name' => 'Agent', 'last_name' => 'Anderson'], + ['first_name' => 'Agent', 'last_name' => 'Smith'], + ]; + + foreach ($assertions as $key => $assertion) { + $this->assertSame( + $assertion, + $users[$key]->only('first_name', 'last_name'), + ); + } + }; + + $usersByClass = FactoryTestUserFactory::times(4) + ->state( + new CrossJoinSequence( + [['first_name' => 'Thomas'], ['first_name' => 'Agent']], + [['last_name' => 'Anderson'], ['last_name' => 'Smith']], + ), + ) + ->make(); + + $assert($usersByClass); + + $usersByMethod = FactoryTestUserFactory::times(4) + ->crossJoinSequence( + [['first_name' => 'Thomas'], ['first_name' => 'Agent']], + [['last_name' => 'Anderson'], ['last_name' => 'Smith']], + ) + ->make(); + + $assert($usersByMethod); + } + + public function test_resolve_nested_model_factories() + { + Factory::useNamespace('Factories\\'); + + $resolves = [ + 'App\\Foo' => 'Factories\\FooFactory', + 'App\\Models\\Foo' => 'Factories\\FooFactory', + 'App\\Models\\Nested\\Foo' => 'Factories\\Nested\\FooFactory', + 'App\\Models\\Really\\Nested\\Foo' => 'Factories\\Really\\Nested\\FooFactory', + ]; + + foreach ($resolves as $model => $factory) { + $this->assertEquals($factory, Factory::resolveFactoryName($model)); + } + } + + public function test_resolve_nested_model_name_from_factory() + { + Container::getInstance()->instance(Application::class, $app = m::mock(Application::class)); + $app->shouldReceive('getNamespace')->andReturn('Hypervel\\Tests\\Database\\Laravel\\Fixtures\\'); + + Factory::useNamespace('Hypervel\\Tests\\Database\\Laravel\\Fixtures\\Factories\\'); + + $factory = Price::factory(); + + $this->assertSame(Price::class, $factory->modelName()); + } + + public function test_resolve_non_app_nested_model_factories() + { + Container::getInstance()->instance(Application::class, $app = m::mock(Application::class)); + $app->shouldReceive('getNamespace')->andReturn('Foo\\'); + + Factory::useNamespace('Factories\\'); + + $resolves = [ + 'Foo\\Bar' => 'Factories\\BarFactory', + 'Foo\\Models\\Bar' => 'Factories\\BarFactory', + 'Foo\\Models\\Nested\\Bar' => 'Factories\\Nested\\BarFactory', + 'Foo\\Models\\Really\\Nested\\Bar' => 'Factories\\Really\\Nested\\BarFactory', + ]; + + foreach ($resolves as $model => $factory) { + $this->assertEquals($factory, Factory::resolveFactoryName($model)); + } + } + + public function test_model_has_factory() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $this->assertInstanceOf(FactoryTestUserFactory::class, FactoryTestUser::factory()); + } + + public function test_dynamic_has_and_for_methods() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $user = FactoryTestUserFactory::new()->hasPosts(3)->create(); + + $this->assertCount(3, $user->posts); + + $post = FactoryTestPostFactory::new() + ->forAuthor(['name' => 'Taylor Otwell']) + ->hasComments(2) + ->create(); + + $this->assertInstanceOf(FactoryTestUser::class, $post->author); + $this->assertSame('Taylor Otwell', $post->author->name); + $this->assertCount(2, $post->comments); + } + + public function test_can_be_macroable() + { + $factory = FactoryTestUserFactory::new(); + $factory->macro('getFoo', function () { + return 'Hello World'; + }); + + $this->assertSame('Hello World', $factory->getFoo()); + } + + public function test_factory_can_conditionally_execute_code() + { + FactoryTestUserFactory::new() + ->when(true, function () { + $this->assertTrue(true); + }) + ->when(false, function () { + $this->fail('Unreachable code that has somehow been reached.'); + }) + ->unless(false, function () { + $this->assertTrue(true); + }) + ->unless(true, function () { + $this->fail('Unreachable code that has somehow been reached.'); + }); + } + + public function test_dynamic_trashed_state_for_softdeletes_models() + { + $now = Carbon::create(2020, 6, 7, 8, 9); + Carbon::setTestNow($now); + $post = FactoryTestPostFactory::new()->trashed()->create(); + + $this->assertTrue($post->deleted_at->equalTo($now->subDay())); + + $deleted_at = Carbon::create(2020, 1, 2, 3, 4, 5); + $post = FactoryTestPostFactory::new()->trashed($deleted_at)->create(); + + $this->assertTrue($deleted_at->equalTo($post->deleted_at)); + + Carbon::setTestNow(); + } + + public function test_dynamic_trashed_state_respects_existing_state() + { + $now = Carbon::create(2020, 6, 7, 8, 9); + Carbon::setTestNow($now); + $comment = FactoryTestCommentFactory::new()->trashed()->create(); + + $this->assertTrue($comment->deleted_at->equalTo($now->subWeek())); + + Carbon::setTestNow(); + } + + public function test_dynamic_trashed_state_throws_exception_when_not_a_softdeletes_model() + { + $this->expectException(BadMethodCallException::class); + FactoryTestUserFactory::new()->trashed()->create(); + } + + public function test_model_instances_can_be_used_in_place_of_nested_factories() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $user = FactoryTestUserFactory::new()->create(); + $post = FactoryTestPostFactory::new() + ->recycle($user) + ->hasComments(2) + ->create(); + + $this->assertSame(1, FactoryTestUser::count()); + $this->assertEquals($user->id, $post->user_id); + $this->assertEquals($user->id, $post->comments[0]->user_id); + $this->assertEquals($user->id, $post->comments[1]->user_id); + } + + public function test_for_method_recycles_models() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $user = FactoryTestUserFactory::new()->create(); + $post = FactoryTestPostFactory::new() + ->recycle($user) + ->for(FactoryTestUserFactory::new()) + ->create(); + + $this->assertSame(1, FactoryTestUser::count()); + } + + public function test_has_method_does_not_reassign_the_parent() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $post = FactoryTestPostFactory::new()->create(); + $user = FactoryTestUserFactory::new() + ->recycle($post) + // The recycled post already belongs to a user, so it shouldn't be recycled here. + ->has(FactoryTestPostFactory::new(), 'posts') + ->create(); + + $this->assertSame(2, FactoryTestPost::count()); + } + + public function test_multiple_models_can_be_provided_to_recycle() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $users = FactoryTestUserFactory::new()->count(3)->create(); + + $posts = FactoryTestPostFactory::new() + ->recycle($users) + ->for(FactoryTestUserFactory::new()) + ->has(FactoryTestCommentFactory::new()->count(5), 'comments') + ->count(2) + ->create(); + + $this->assertSame(3, FactoryTestUser::count()); + } + + public function test_recycled_models_can_be_combined_with_multiple_calls() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $users = FactoryTestUserFactory::new() + ->count(2) + ->create(); + $posts = FactoryTestPostFactory::new() + ->recycle($users) + ->count(2) + ->create(); + $additionalUser = FactoryTestUserFactory::new() + ->create(); + $additionalPost = FactoryTestPostFactory::new() + ->recycle($additionalUser) + ->create(); + + $this->assertSame(3, FactoryTestUser::count()); + $this->assertSame(3, FactoryTestPost::count()); + + $comments = FactoryTestCommentFactory::new() + ->recycle($users) + ->recycle($posts) + ->recycle([$additionalUser, $additionalPost]) + ->count(5) + ->create(); + + $this->assertSame(3, FactoryTestUser::count()); + $this->assertSame(3, FactoryTestPost::count()); + } + + public function test_no_models_can_be_provided_to_recycle() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model.'Factory'; + }); + + $posts = FactoryTestPostFactory::new() + ->recycle([]) + ->count(2) + ->create(); + + $this->assertSame(2, FactoryTestPost::count()); + $this->assertSame(2, FactoryTestUser::count()); + } + + public function test_can_disable_relationships() + { + $post = FactoryTestPostFactory::new() + ->withoutParents() + ->make(); + + $this->assertNull($post->user_id); + } + + public function test_can_disable_relationships_explicitly_by_model_name() + { + $comment = FactoryTestCommentFactory::new() + ->withoutParents([FactoryTestUser::class]) + ->make(); + + $this->assertNull($comment->user_id); + $this->assertNotNull($comment->commentable->id); + } + + public function test_can_disable_relationships_explicitly_by_attribute_name() + { + $comment = FactoryTestCommentFactory::new() + ->withoutParents(['user_id']) + ->make(); + + $this->assertNull($comment->user_id); + $this->assertNotNull($comment->commentable->id); + } + + public function test_can_disable_relationships_explicitly_by_both_attribute_name_and_model_name() + { + $comment = FactoryTestCommentFactory::new() + ->withoutParents(['user_id', FactoryTestPost::class]) + ->make(); + + $this->assertNull($comment->user_id); + $this->assertNull($comment->commentable->id); + } + + public function test_can_default_to_without_parents() + { + FactoryTestPostFactory::dontExpandRelationshipsByDefault(); + + $post = FactoryTestPostFactory::new()->make(); + $this->assertNull($post->user_id); + + FactoryTestPostFactory::expandRelationshipsByDefault(); + $postWithParents = FactoryTestPostFactory::new()->create(); + $this->assertNotNull($postWithParents->user_id); + } + + public function test_factory_model_names_correct() + { + $this->assertEquals(FactoryTestUseFactoryAttribute::factory()->modelName(), FactoryTestUseFactoryAttribute::class); + $this->assertEquals(FactoryTestGuessModel::factory()->modelName(), FactoryTestGuessModel::class); + } + + public function test_factory_global_model_resolver() + { + Factory::guessModelNamesUsing(function ($factory) { + return __NAMESPACE__.'\\'.Str::replaceLast('Factory', '', class_basename($factory::class)); + }); + + $this->assertEquals(FactoryTestGuessModel::factory()->modelName(), FactoryTestGuessModel::class); + $this->assertEquals(FactoryTestUseFactoryAttribute::factory()->modelName(), FactoryTestUseFactoryAttribute::class); + + $this->assertEquals(FactoryTestUseFactoryAttributeFactory::new()->modelName(), FactoryTestUseFactoryAttribute::class); + $this->assertEquals(FactoryTestGuessModelFactory::new()->modelName(), FactoryTestGuessModel::class); + } + + public function test_factory_model_has_many_relationship_has_pending_attributes() + { + FactoryTestUser::factory()->has(new FactoryTestPostFactory(), 'postsWithFooBarBazAsTitle')->create(); + + $this->assertEquals('foo bar baz', FactoryTestPost::first()->title); + } + + public function test_factory_model_has_many_relationship_has_pending_attributes_override() + { + FactoryTestUser::factory()->has((new FactoryTestPostFactory())->state(['title' => 'other title']), 'postsWithFooBarBazAsTitle')->create(); + + $this->assertEquals('other title', FactoryTestPost::first()->title); + } + + public function test_factory_model_has_one_relationship_has_pending_attributes() + { + FactoryTestUser::factory()->has(new FactoryTestPostFactory(), 'postWithFooBarBazAsTitle')->create(); + + $this->assertEquals('foo bar baz', FactoryTestPost::first()->title); + } + + public function test_factory_model_has_one_relationship_has_pending_attributes_override() + { + FactoryTestUser::factory()->has((new FactoryTestPostFactory())->state(['title' => 'other title']), 'postWithFooBarBazAsTitle')->create(); + + $this->assertEquals('other title', FactoryTestPost::first()->title); + } + + public function test_factory_model_belongs_to_many_relationship_has_pending_attributes() + { + FactoryTestUser::factory()->has(new FactoryTestRoleFactory(), 'rolesWithFooBarBazAsName')->create(); + + $this->assertEquals('foo bar baz', FactoryTestRole::first()->name); + } + + public function test_factory_model_belongs_to_many_relationship_has_pending_attributes_override() + { + FactoryTestUser::factory()->has((new FactoryTestRoleFactory())->state(['name' => 'other name']), 'rolesWithFooBarBazAsName')->create(); + + $this->assertEquals('other name', FactoryTestRole::first()->name); + } + + public function test_factory_model_morph_many_relationship_has_pending_attributes() + { + (new FactoryTestPostFactory())->has(new FactoryTestCommentFactory(), 'commentsWithFooBarBazAsBody')->create(); + + $this->assertEquals('foo bar baz', FactoryTestComment::first()->body); + } + + public function test_factory_model_morph_many_relationship_has_pending_attributes_override() + { + (new FactoryTestPostFactory())->has((new FactoryTestCommentFactory())->state(['body' => 'other body']), 'commentsWithFooBarBazAsBody')->create(); + + $this->assertEquals('other body', FactoryTestComment::first()->body); + } + + public function test_factory_can_insert() + { + (new FactoryTestPostFactory()) + ->count(5) + ->recycle([ + (new FactoryTestUserFactory())->create(['name' => Name::Taylor]), + (new FactoryTestUserFactory())->create(['name' => Name::Shad, 'created_at' => now()]), + ]) + ->state(['title' => 'hello']) + ->insert(); + $this->assertCount(5, $posts = FactoryTestPost::query()->where('title', 'hello')->get()); + $this->assertEquals(strtoupper($posts[0]->user->name), $posts[0]->upper_case_name); + $this->assertEquals( + 2, + ($users = FactoryTestUser::query()->get())->count() + ); + $this->assertCount(1, $users->where('name', 'totwell')); + $this->assertCount(1, $users->where('name', 'shaedrich')); + } + + public function test_factory_can_insert_with_hidden() + { + (new FactoryTestUserFactory())->forEachSequence(['name' => Name::Taylor, 'options' => 'abc'])->insert(); + $user = DB::table('users')->sole(); + $this->assertEquals('abc', $user->options); + $userModel = FactoryTestUser::query()->sole(); + $this->assertEquals('abc', $userModel->options); + } + + public function test_factory_can_insert_with_array_casts() + { + (new FactoryTestUserWithArrayFactory())->count(2)->insert(); + $users = DB::table('users')->get(); + foreach ($users as $user) { + $this->assertEquals(['rtj'], json_decode($user->options, true)); + $createdAt = Carbon::parse($user->created_at); + $updatedAt = Carbon::parse($user->updated_at); + $this->assertEquals($updatedAt, $createdAt); + } + } + + /** + * Get a database connection instance. + * + * @return \Hypervel\Database\ConnectionInterface + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Hypervel\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class FactoryTestUserFactory extends Factory +{ + protected ?string $model = FactoryTestUser::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + 'options' => null, + ]; + } +} + +class FactoryTestUser extends Eloquent +{ + use HasFactory; + + protected ?string $table = 'users'; + protected array $hidden = ['options']; + protected array $withCount = ['posts']; + protected array $with = ['posts']; + + public function posts() + { + return $this->hasMany(FactoryTestPost::class, 'user_id'); + } + + public function postsWithFooBarBazAsTitle() + { + return $this->hasMany(FactoryTestPost::class, 'user_id')->withAttributes(['title' => 'foo bar baz']); + } + + public function postWithFooBarBazAsTitle() + { + return $this->hasOne(FactoryTestPost::class, 'user_id')->withAttributes(['title' => 'foo bar baz']); + } + + public function roles() + { + return $this->belongsToMany(FactoryTestRole::class, 'role_user', 'user_id', 'role_id')->withPivot('admin'); + } + + public function rolesWithFooBarBazAsName() + { + return $this->belongsToMany(FactoryTestRole::class, 'role_user', 'user_id', 'role_id')->withPivot('admin')->withAttributes(['name' => 'foo bar baz']); + } + + public function factoryTestRoles() + { + return $this->belongsToMany(FactoryTestRole::class, 'role_user', 'user_id', 'role_id')->withPivot('admin'); + } +} + +class FactoryTestPostFactory extends Factory +{ + protected ?string $model = FactoryTestPost::class; + + public function definition(): array + { + return [ + 'user_id' => FactoryTestUserFactory::new(), + 'title' => $this->faker->name(), + ]; + } +} + +class FactoryTestPost extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'posts'; + + protected array $appends = ['upper_case_name']; + + public function upperCaseName(): Attribute + { + return Attribute::get(fn ($attr) => Str::upper($this->user->name)); + } + + public function user() + { + return $this->belongsTo(FactoryTestUser::class, 'user_id'); + } + + public function factoryTestUser() + { + return $this->belongsTo(FactoryTestUser::class, 'user_id'); + } + + public function author() + { + return $this->belongsTo(FactoryTestUser::class, 'user_id'); + } + + public function comments() + { + return $this->morphMany(FactoryTestComment::class, 'commentable'); + } + + public function commentsWithFooBarBazAsBody() + { + return $this->morphMany(FactoryTestComment::class, 'commentable')->withAttributes(['body' => 'foo bar baz']); + } +} + +class FactoryTestCommentFactory extends Factory +{ + protected ?string $model = FactoryTestComment::class; + + public function definition(): array + { + return [ + 'commentable_id' => FactoryTestPostFactory::new(), + 'commentable_type' => FactoryTestPost::class, + 'user_id' => fn () => FactoryTestUserFactory::new(), + 'body' => $this->faker->name(), + ]; + } + + public function trashed() + { + return $this->state([ + 'deleted_at' => Carbon::now()->subWeek(), + ]); + } +} + +class FactoryTestComment extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'comments'; + + public function commentable() + { + return $this->morphTo(); + } +} + +class FactoryTestRoleFactory extends Factory +{ + protected ?string $model = FactoryTestRole::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + ]; + } +} + +class FactoryTestRole extends Eloquent +{ + protected ?string $table = 'roles'; + + protected array $touches = ['users']; + + public function users() + { + return $this->belongsToMany(FactoryTestUser::class, 'role_user', 'role_id', 'user_id')->withPivot('admin'); + } +} + +class FactoryTestGuessModelFactory extends Factory +{ + protected static function appNamespace(): string + { + return __NAMESPACE__.'\\'; + } + + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + ]; + } +} + +class FactoryTestGuessModel extends Eloquent +{ + use HasFactory; + + protected static $factory = FactoryTestGuessModelFactory::class; +} + +class FactoryTestUseFactoryAttributeFactory extends Factory +{ + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + ]; + } +} + +#[UseFactory(FactoryTestUseFactoryAttributeFactory::class)] +class FactoryTestUseFactoryAttribute extends Eloquent +{ + use HasFactory; +} + +class FactoryTestUserWithArray extends Eloquent +{ + protected ?string $table = 'users'; + + protected function casts(): array + { + return ['options' => 'array']; + } +} + +class FactoryTestUserWithArrayFactory extends Factory +{ + protected ?string $model = FactoryTestUserWithArray::class; + + public function definition(): array + { + return [ + 'name' => 'killer mike', + 'options' => ['rtj'], + ]; + } +} + +enum Name: string +{ + case Taylor = 'totwell'; + case Shad = 'shaedrich'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentGlobalScopesTest.php b/tests/Database/Laravel/DatabaseEloquentGlobalScopesTest.php new file mode 100644 index 000000000..1c74bb0aa --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentGlobalScopesTest.php @@ -0,0 +1,292 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ])->bootEloquent(); + } + + protected function tearDown(): void + { + Model::unsetConnectionResolver(); + + parent::tearDown(); + } + + public function testGlobalScopeIsApplied() + { + $model = new EloquentGlobalScopesTestModel; + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testGlobalScopeCanBeRemoved() + { + $model = new EloquentGlobalScopesTestModel; + $query = $model->newQuery()->withoutGlobalScope(ActiveScope::class); + $this->assertSame('select * from "table"', $query->toSql()); + $this->assertEquals([], $query->getBindings()); + } + + public function testClassNameGlobalScopeIsApplied() + { + $model = new EloquentClassNameGlobalScopesTestModel; + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testGlobalScopeInAttributeIsApplied() + { + $model = new EloquentGlobalScopeInAttributeTestModel; + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testGlobalScopeInInheritedAttributeIsApplied() + { + $model = new EloquentGlobalScopeInInheritedAttributeTestModel; + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testClosureGlobalScopeIsApplied() + { + $model = new EloquentClosureGlobalScopesTestModel; + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ? order by "name" asc', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testGlobalScopesCanBeRegisteredViaArray() + { + $model = new EloquentGlobalScopesArrayTestModel; + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ? order by "name" asc', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testClosureGlobalScopeCanBeRemoved() + { + $model = new EloquentClosureGlobalScopesTestModel; + $query = $model->newQuery()->withoutGlobalScope('active_scope'); + $this->assertSame('select * from "table" order by "name" asc', $query->toSql()); + $this->assertEquals([], $query->getBindings()); + } + + public function testGlobalScopeCanBeRemovedAfterTheQueryIsExecuted() + { + $model = new EloquentClosureGlobalScopesTestModel; + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ? order by "name" asc', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + + $query->withoutGlobalScope('active_scope'); + $this->assertSame('select * from "table" order by "name" asc', $query->toSql()); + $this->assertEquals([], $query->getBindings()); + } + + public function testAllGlobalScopesCanBeRemoved() + { + $model = new EloquentClosureGlobalScopesTestModel; + $query = $model->newQuery()->withoutGlobalScopes(); + $this->assertSame('select * from "table"', $query->toSql()); + $this->assertEquals([], $query->getBindings()); + + $query = EloquentClosureGlobalScopesTestModel::withoutGlobalScopes(); + $this->assertSame('select * from "table"', $query->toSql()); + $this->assertEquals([], $query->getBindings()); + } + + public function testAllGlobalScopesCanBeRemovedExceptSpecified() + { + $model = new EloquentClosureGlobalScopesTestModel; + $query = $model->newQuery()->withoutGlobalScopesExcept(['active_scope']); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + + $query = EloquentClosureGlobalScopesTestModel::withoutGlobalScopesExcept(['active_scope']); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testGlobalScopesWithOrWhereConditionsAreNested() + { + $model = new EloquentClosureGlobalScopesWithOrTestModel; + + $query = $model->newQuery(); + $this->assertSame('select "email", "password" from "table" where ("email" = ? or "email" = ?) and "active" = ? order by "name" asc', $query->toSql()); + $this->assertEquals(['taylor@gmail.com', 'someone@else.com', 1], $query->getBindings()); + + $query = $model->newQuery()->where('col1', 'val1')->orWhere('col2', 'val2'); + $this->assertSame('select "email", "password" from "table" where ("col1" = ? or "col2" = ?) and ("email" = ? or "email" = ?) and "active" = ? order by "name" asc', $query->toSql()); + $this->assertEquals(['val1', 'val2', 'taylor@gmail.com', 'someone@else.com', 1], $query->getBindings()); + } + + public function testRegularScopesWithOrWhereConditionsAreNested() + { + $query = EloquentClosureGlobalScopesTestModel::withoutGlobalScopes()->where('foo', 'foo')->orWhere('bar', 'bar')->approved(); + + $this->assertSame('select * from "table" where ("foo" = ? or "bar" = ?) and ("approved" = ? or "should_approve" = ?)', $query->toSql()); + $this->assertEquals(['foo', 'bar', 1, 0], $query->getBindings()); + } + + public function testScopesStartingWithOrBooleanArePreserved() + { + $query = EloquentClosureGlobalScopesTestModel::withoutGlobalScopes()->where('foo', 'foo')->orWhere('bar', 'bar')->orApproved(); + + $this->assertSame('select * from "table" where ("foo" = ? or "bar" = ?) or ("approved" = ? or "should_approve" = ?)', $query->toSql()); + $this->assertEquals(['foo', 'bar', 1, 0], $query->getBindings()); + } + + public function testHasQueryWhereBothModelsHaveGlobalScopes() + { + $query = EloquentGlobalScopesWithRelationModel::has('related')->where('bar', 'baz'); + + $subQuery = 'select * from "table" where "table2"."id" = "table"."related_id" and "foo" = ? and "active" = ?'; + $mainQuery = 'select * from "table2" where exists ('.$subQuery.') and "bar" = ? and "active" = ? order by "name" asc'; + + $this->assertEquals($mainQuery, $query->toSql()); + $this->assertEquals(['bar', 1, 'baz', 1], $query->getBindings()); + } +} + +class EloquentClosureGlobalScopesTestModel extends Model +{ + protected ?string $table = 'table'; + + public static function boot(): void + { + static::addGlobalScope(function ($query) { + $query->orderBy('name'); + }); + + static::addGlobalScope('active_scope', function ($query) { + $query->where('active', 1); + }); + + parent::boot(); + } + + public function scopeApproved($query) + { + return $query->where('approved', 1)->orWhere('should_approve', 0); + } + + public function scopeOrApproved($query) + { + return $query->orWhere('approved', 1)->orWhere('should_approve', 0); + } +} + +class EloquentGlobalScopesWithRelationModel extends EloquentClosureGlobalScopesTestModel +{ + protected ?string $table = 'table2'; + + public function related() + { + return $this->hasMany(EloquentGlobalScopesTestModel::class, 'related_id')->where('foo', 'bar'); + } +} + +class EloquentClosureGlobalScopesWithOrTestModel extends EloquentClosureGlobalScopesTestModel +{ + public static function boot(): void + { + static::addGlobalScope('or_scope', function ($query) { + $query->where('email', 'taylor@gmail.com')->orWhere('email', 'someone@else.com'); + }); + + static::addGlobalScope(function ($query) { + $query->select('email', 'password'); + }); + + parent::boot(); + } +} + +class EloquentGlobalScopesTestModel extends Model +{ + protected ?string $table = 'table'; + + public static function boot(): void + { + static::addGlobalScope(new ActiveScope); + + parent::boot(); + } +} + +class EloquentClassNameGlobalScopesTestModel extends Model +{ + protected ?string $table = 'table'; + + public static function boot(): void + { + static::addGlobalScope(ActiveScope::class); + + parent::boot(); + } +} + +class EloquentGlobalScopesArrayTestModel extends Model +{ + protected ?string $table = 'table'; + + public static function boot(): void + { + static::addGlobalScopes([ + 'active_scope' => new ActiveScope, + fn ($query) => $query->orderBy('name'), + ]); + + parent::boot(); + } +} + +#[ScopedBy(ActiveScope::class)] +class EloquentGlobalScopeInAttributeTestModel extends Model +{ + protected ?string $table = 'table'; +} + +class ActiveScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('active', 1); + } +} + +#[ScopedBy(ActiveScope::class)] +trait EloquentGlobalScopeInInheritedAttributeTestTrait +{ + // +} + +class EloquentGlobalScopeInInheritedAttributeTestModel extends Model +{ + use EloquentGlobalScopeInInheritedAttributeTestTrait; + + protected ?string $table = 'table'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasManyCreateOrFirstTest.php b/tests/Database/Laravel/DatabaseEloquentHasManyCreateOrFirstTest.php new file mode 100755 index 000000000..c29ff4837 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasManyCreateOrFirstTest.php @@ -0,0 +1,372 @@ +id = 123; + $this->mockConnectionForModel($model, 'SQLite', [456]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection()->expects('insert')->with( + 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)', + ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testCreateOrFirstMethodRetrievesExistingRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $sql = 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)'; + $bindings = ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], false) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodCreatesNewRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite', [456]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)', + ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $result = $model->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([]); + + $sql = 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)'; + $bindings = ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], false) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodCreatesNewRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite', [456]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)', + ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesExistingRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $model->getConnection()->expects('update')->with( + 'update "child_table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 456], + )->andReturn(1); + + $result = $model->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([]); + + $sql = 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)'; + $bindings = ['foo', 'baz', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], false) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection()->expects('update')->with( + 'update "child_table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 456], + )->andReturn(1); + + $result = $model->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void + { + $grammarClass = 'Hypervel\Database\Query\Grammars\\'.$database.'Grammar'; + $processorClass = 'Hypervel\Database\Query\Processors\\'.$database.'Processor'; + $processor = new $processorClass; + $connection = m::mock(Connection::class, ['getPostProcessor' => $processor]); + $grammar = new $grammarClass($connection); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new Builder($connection, $grammar, $processor); + }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $resolver = m::mock(ConnectionResolverInterface::class, ['connection' => $connection]); + + $class = get_class($model); + $class::setConnectionResolver($resolver); + + $connection->shouldReceive('getPdo')->andReturn($pdo = m::mock(PDO::class)); + + foreach ($lastInsertIds as $id) { + $pdo->expects('lastInsertId')->andReturn($id); + } + } +} + +/** + * @property int $id + */ +class HasManyCreateOrFirstTestParentModel extends Model +{ + protected ?string $table = 'parent_table'; + protected array $guarded = []; + + public function children(): HasMany + { + return $this->hasMany(HasManyCreateOrFirstTestChildModel::class, 'parent_id'); + } +} + +/** + * @property int $id + * @property int $parent_id + */ +class HasManyCreateOrFirstTestChildModel extends Model +{ + protected ?string $table = 'child_table'; + protected array $guarded = []; +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasManyTest.php b/tests/Database/Laravel/DatabaseEloquentHasManyTest.php new file mode 100755 index 000000000..8572d6119 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasManyTest.php @@ -0,0 +1,489 @@ +getRelation(); + $instance = $this->expectNewModel($relation, ['name' => 'taylor']); + $instance->shouldReceive('save')->never(); + + $this->assertEquals($instance, $relation->make(['name' => 'taylor'])); + } + + public function testMakeManyCreatesARelatedModelForEachRecord() + { + $records = [ + 'taylor' => ['name' => 'taylor'], + 'colin' => ['name' => 'colin'], + ]; + + // Use concrete stub to properly test distinct instances and save() behavior + EloquentHasManyRelatedStub::resetState(); + $relation = $this->getRelationWithConcreteRelated(); + + $instances = $relation->makeMany($records); + + $this->assertInstanceOf(Collection::class, $instances); + $this->assertCount(2, $instances); + // Verify distinct instances were created (not the same object) + $this->assertNotSame($instances[0], $instances[1]); + // Verify save() was never called + $this->assertFalse(EloquentHasManyRelatedStub::$saveCalled); + // Verify foreign key was set on each instance + $this->assertEquals(1, $instances[0]->getAttribute('foreign_key')); + $this->assertEquals(1, $instances[1]->getAttribute('foreign_key')); + } + + public function testCreateMethodProperlyCreatesNewModel() + { + $relation = $this->getRelation(); + $created = $this->expectCreatedModel($relation, ['name' => 'taylor']); + + $this->assertEquals($created, $relation->create(['name' => 'taylor'])); + } + + public function testForceCreateMethodProperlyCreatesNewModel() + { + $relation = $this->getRelation(); + $created = $this->expectForceCreatedModel($relation, ['name' => 'taylor']); + + $this->assertEquals($created, $relation->forceCreate(['name' => 'taylor'])); + $this->assertEquals(1, $created->getAttribute('foreign_key')); + } + + public function testFindOrNewMethodFindsModel() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('find')->once()->with('foo', ['*'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->never(); + + $this->assertInstanceOf(Model::class, $relation->findOrNew('foo')); + } + + public function testFindOrNewMethodReturnsNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('find')->once()->with('foo', ['*'])->andReturn(null); + $relation->getRelated()->shouldReceive('newInstance')->once()->with()->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('foreign_key', 1); + + $this->assertInstanceOf(Model::class, $relation->findOrNew('foo')); + } + + public function testFirstOrNewMethodFindsFirstModel() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrNew(['foo'])); + } + + public function testFirstOrNewMethodWithValuesFindsFirstModel() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + $model->shouldReceive('setAttribute')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrNew(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testFirstOrNewMethodReturnsNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $model = $this->expectNewModel($relation, ['foo']); + + $this->assertEquals($model, $relation->firstOrNew(['foo'])); + } + + public function testFirstOrNewMethodWithValuesCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $model = $this->expectNewModel($relation, ['foo' => 'bar', 'baz' => 'qux']); + + $this->assertEquals($model, $relation->firstOrNew(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testFirstOrCreateMethodFindsFirstModel() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + $model->shouldReceive('setAttribute')->never(); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrCreate(['foo'])); + } + + public function testFirstOrCreateMethodWithValuesFindsFirstModel() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + $model->shouldReceive('setAttribute')->never(); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrCreate(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testFirstOrCreateMethodCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(fn ($scope) => $scope()); + $model = $this->expectCreatedModel($relation, ['foo']); + + $this->assertEquals($model, $relation->firstOrCreate(['foo'])); + } + + public function testFirstOrCreateMethodWithValuesCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(fn ($scope) => $scope()); + $model = $this->expectCreatedModel($relation, ['foo' => 'bar', 'baz' => 'qux']); + + $this->assertEquals($model, $relation->firstOrCreate(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testCreateOrFirstMethodWithValuesFindsFirstModel() + { + $relation = $this->getRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn(m::mock(Model::class, function ($model) { + $model->shouldReceive('setAttribute')->once()->with('foreign_key', 1); + $model->shouldReceive('save')->once()->andThrow( + new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + ); + })); + + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); + $relation->getQuery()->shouldReceive('useWritePdo')->once()->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + + $this->assertInstanceOf(Model::class, $found = $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + $this->assertSame($model, $found); + } + + public function testCreateOrFirstMethodCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + $model = $this->expectCreatedModel($relation, ['foo']); + + $this->assertEquals($model, $relation->createOrFirst(['foo'])); + } + + public function testCreateOrFirstMethodWithValuesCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + $model = $this->expectCreatedModel($relation, ['foo' => 'bar', 'baz' => 'qux']); + + $this->assertEquals($model, $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + + $model->wasRecentlyCreated = false; + $model->shouldReceive('fill')->once()->with(['bar'])->andReturn($model); + $model->shouldReceive('save')->once(); + + $this->assertInstanceOf(Model::class, $relation->updateOrCreate(['foo'], ['bar'])); + } + + public function testUpdateOrCreateMethodCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo', 'bar'])->andReturn($model = m::mock(Model::class)); + + $model->wasRecentlyCreated = true; + $model->shouldReceive('save')->once()->andReturn(true); + $model->shouldReceive('setAttribute')->once()->with('foreign_key', 1); + + $this->assertInstanceOf(Model::class, $relation->updateOrCreate(['foo'], ['bar'])); + } + + public function testRelationUpsertFillsForeignKey() + { + $relation = $this->getRelation(); + + $relation->getQuery()->shouldReceive('upsert')->once()->with( + [ + ['email' => 'foo3', 'name' => 'bar', $relation->getForeignKeyName() => $relation->getParentKey()], + ], + ['email'], + ['name'] + )->andReturn(1); + + $relation->upsert( + ['email' => 'foo3', 'name' => 'bar'], + ['email'], + ['name'] + ); + + $relation->getQuery()->shouldReceive('upsert')->once()->with( + [ + ['email' => 'foo3', 'name' => 'bar', $relation->getForeignKeyName() => $relation->getParentKey()], + ['name' => 'bar2', 'email' => 'foo2', $relation->getForeignKeyName() => $relation->getParentKey()], + ], + ['email'], + ['name'] + )->andReturn(2); + + $relation->upsert( + [ + ['email' => 'foo3', 'name' => 'bar'], + ['name' => 'bar2', 'email' => 'foo2'], + ], + ['email'], + ['name'] + ); + } + + public function testRelationIsProperlyInitialized() + { + $relation = $this->getRelation(); + $model = m::mock(Model::class); + $relation->getRelated()->shouldReceive('newCollection')->andReturnUsing(function ($array = []) { + return new Collection($array); + }); + $model->shouldReceive('setRelation')->once()->with('foo', m::type(Collection::class)); + $models = $relation->initRelation([$model], 'foo'); + + $this->assertEquals([$model], $models); + } + + public function testEagerConstraintsAreProperlyAdded() + { + $relation = $this->getRelation(); + $relation->getParent()->shouldReceive('getKeyName')->once()->andReturn('id'); + $relation->getParent()->shouldReceive('getKeyType')->once()->andReturn('int'); + $relation->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('table.foreign_key', [1, 2]); + $model1 = new EloquentHasManyModelStub; + $model1->id = 1; + $model2 = new EloquentHasManyModelStub; + $model2->id = 2; + $relation->addEagerConstraints([$model1, $model2]); + } + + public function testEagerConstraintsAreProperlyAddedWithStringKey() + { + $relation = $this->getRelation(); + $relation->getParent()->shouldReceive('getKeyName')->once()->andReturn('id'); + $relation->getParent()->shouldReceive('getKeyType')->once()->andReturn('string'); + $relation->getQuery()->shouldReceive('whereIn')->once()->with('table.foreign_key', [1, 2]); + $model1 = new EloquentHasManyModelStub; + $model1->id = 1; + $model2 = new EloquentHasManyModelStub; + $model2->id = 2; + $relation->addEagerConstraints([$model1, $model2]); + } + + public function testModelsAreProperlyMatchedToParents() + { + $relation = $this->getRelation(); + + $result1 = new EloquentHasManyModelStub; + $result1->foreign_key = 1; + $result2 = new EloquentHasManyModelStub; + $result2->foreign_key = 2; + $result3 = new EloquentHasManyModelStub; + $result3->foreign_key = 2; + + $model1 = new EloquentHasManyModelStub; + $model1->id = 1; + $model2 = new EloquentHasManyModelStub; + $model2->id = 2; + $model3 = new EloquentHasManyModelStub; + $model3->id = 3; + + $relation->getRelated()->shouldReceive('newCollection')->andReturnUsing(function ($array) { + return new Collection($array); + }); + $models = $relation->match([$model1, $model2, $model3], new Collection([$result1, $result2, $result3]), 'foo'); + + $this->assertEquals(1, $models[0]->foo[0]->foreign_key); + $this->assertCount(1, $models[0]->foo); + $this->assertEquals(2, $models[1]->foo[0]->foreign_key); + $this->assertEquals(2, $models[1]->foo[1]->foreign_key); + $this->assertCount(2, $models[1]->foo); + $this->assertNull($models[2]->foo); + } + + public function testCreateManyCreatesARelatedModelForEachRecord() + { + $records = [ + 'taylor' => ['name' => 'taylor'], + 'colin' => ['name' => 'colin'], + ]; + + $relation = $this->getRelation(); + $relation->getRelated()->shouldReceive('newCollection')->once()->andReturn(new Collection); + + $taylor = $this->expectCreatedModel($relation, ['name' => 'taylor']); + $colin = $this->expectCreatedModel($relation, ['name' => 'colin']); + + $instances = $relation->createMany($records); + $this->assertInstanceOf(Collection::class, $instances); + $this->assertEquals($taylor, $instances[0]); + $this->assertEquals($colin, $instances[1]); + } + + protected function getRelation() + { + $queryBuilder = m::mock(QueryBuilder::class); + $builder = m::mock(Builder::class, [$queryBuilder]); + $builder->shouldReceive('whereNotNull')->with('table.foreign_key'); + $builder->shouldReceive('where')->with('table.foreign_key', '=', 1); + $related = m::mock(Model::class); + $builder->shouldReceive('getModel')->andReturn($related); + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); + $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + + return new HasMany($builder, $parent, 'table.foreign_key', 'id'); + } + + protected function getRelationWithConcreteRelated(): HasMany + { + $queryBuilder = m::mock(QueryBuilder::class); + $builder = m::mock(Builder::class, [$queryBuilder]); + $builder->shouldReceive('whereNotNull')->with('table.foreign_key'); + $builder->shouldReceive('where')->with('table.foreign_key', '=', 1); + + // Use concrete stub instead of mock + $related = new EloquentHasManyRelatedStub; + $builder->shouldReceive('getModel')->andReturn($related); + + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); + $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + + return new HasMany($builder, $parent, 'table.foreign_key', 'id'); + } + + protected function expectNewModel($relation, $attributes = null) + { + // Use andReturnSelf() to satisfy static return type of newInstance() + $related = $relation->getRelated(); + $related->shouldReceive('newInstance')->once()->with($attributes)->andReturnSelf(); + $related->shouldReceive('setAttribute')->once()->with('foreign_key', 1)->andReturnSelf(); + + return $related; + } + + protected function expectCreatedModel($relation, $attributes) + { + // Use andReturnSelf() to satisfy static return type of newInstance() + $related = $relation->getRelated(); + $related->shouldReceive('newInstance')->once()->with($attributes)->andReturnSelf(); + $related->shouldReceive('setAttribute')->once()->with('foreign_key', 1)->andReturnSelf(); + $related->shouldReceive('save')->once()->andReturn(true); + + return $related; + } + + protected function expectForceCreatedModel($relation, $attributes) + { + $attributes[$relation->getForeignKeyName()] = $relation->getParentKey(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->with($relation->getForeignKeyName())->andReturn($relation->getParentKey()); + + $relation->getRelated()->shouldReceive('forceCreate')->once()->with($attributes)->andReturn($model); + + return $model; + } +} + +class EloquentHasManyModelStub extends Model +{ + public string|int $foreign_key = 'foreign.value'; +} + +/** + * Concrete test stub that tracks save() calls and returns distinct instances from newInstance(). + * Used to test makeMany() behavior where we need to verify distinct instances are created. + */ +class EloquentHasManyRelatedStub extends Model +{ + public static bool $saveCalled = false; + + public static function resetState(): void + { + static::$saveCalled = false; + } + + public function newInstance(mixed $attributes = [], mixed $exists = false): static + { + $instance = new static; + $instance->exists = $exists; + $instance->setRawAttributes((array) $attributes, true); + + return $instance; + } + + public function save(array $options = []): bool + { + static::$saveCalled = true; + + return true; + } + + public function newCollection(array $models = []): Collection + { + return new Collection($models); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasManyThroughCreateOrFirstTest.php b/tests/Database/Laravel/DatabaseEloquentHasManyThroughCreateOrFirstTest.php new file mode 100644 index 000000000..5e6175afb --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasManyThroughCreateOrFirstTest.php @@ -0,0 +1,437 @@ +id = 123; + $this->mockConnectionForModel($parent, 'SQLite', [789]); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + $parent->getConnection()->expects('insert')->with( + 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $parent->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testCreateOrFirstMethodRetrievesExistingRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $parent->exists = true; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $sql = 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $parent->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $parent->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodCreatesNewRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $parent->exists = true; + $this->mockConnectionForModel($parent, 'SQLite', [789]); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $parent->getConnection()->expects('insert')->with( + 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $parent->exists = true; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $parent->exists = true; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $sql = 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $parent->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ? and "val" = ?) limit 1', + [123, 'foo', 'bar'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodCreatesNewRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $parent->exists = true; + $this->mockConnectionForModel($parent, 'SQLite', [789]); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $parent->getConnection() + ->expects('insert') + ->with( + 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'baz', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + ) + ->andReturnTrue(); + + $result = $parent->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesExistingRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $parent->exists = true; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $parent->getConnection() + ->expects('update') + ->with( + 'update "child" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 789], + ) + ->andReturn(1); + + $result = $parent->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $parent->exists = true; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $sql = 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $parent->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ? and "val" = ?) limit 1', + [123, 'foo', 'bar'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void + { + $grammarClass = 'Hypervel\Database\Query\Grammars\\'.$database.'Grammar'; + $processorClass = 'Hypervel\Database\Query\Processors\\'.$database.'Processor'; + $processor = new $processorClass; + $connection = m::mock(Connection::class, ['getPostProcessor' => $processor]); + $grammar = new $grammarClass($connection); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new Builder($connection, $grammar, $processor); + }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $resolver = m::mock(ConnectionResolverInterface::class, ['connection' => $connection]); + + $class = get_class($model); + $class::setConnectionResolver($resolver); + + $connection->shouldReceive('getPdo')->andReturn($pdo = m::mock(PDO::class)); + + foreach ($lastInsertIds as $id) { + $pdo->expects('lastInsertId')->andReturn($id); + } + } +} + +/** + * @property int $id + * @property int $pivot_id + */ +class HasManyThroughCreateOrFirstTestChildModel extends Model +{ + protected ?string $table = 'child'; + protected array $guarded = []; +} + +/** + * @property int $id + * @property int $parent_id + */ +class HasManyThroughCreateOrFirstTestPivotModel extends Model +{ + protected ?string $table = 'pivot'; + protected array $guarded = []; +} + +/** + * @property int $id + */ +class HasManyThroughCreateOrFirstTestParentModel extends Model +{ + protected ?string $table = 'parent'; + protected array $guarded = []; + + public function children(): HasManyThrough + { + return $this->hasManyThrough( + HasManyThroughCreateOrFirstTestChildModel::class, + HasManyThroughCreateOrFirstTestPivotModel::class, + 'parent_id', + 'pivot_id', + ); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasManyThroughIntegrationTest.php b/tests/Database/Laravel/DatabaseEloquentHasManyThroughIntegrationTest.php new file mode 100644 index 000000000..2d6f09e2f --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasManyThroughIntegrationTest.php @@ -0,0 +1,752 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->unsignedInteger('country_id'); + $table->string('country_short'); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->schema()->create('posts', function ($table) { + $table->increments('id'); + $table->integer('user_id'); + $table->string('title'); + $table->text('body'); + $table->string('email'); + $table->timestamps(); + }); + + $this->schema()->create('countries', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->string('shortname'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('posts'); + $this->schema()->drop('countries'); + + parent::tearDown(); + } + + public function testItLoadsAHasManyThroughRelationWithCustomKeys() + { + $this->seedData(); + $posts = HasManyThroughTestCountry::first()->posts; + + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + } + + public function testItLoadsADefaultHasManyThroughRelation() + { + $this->migrateDefault(); + $this->seedDefaultData(); + + $posts = HasManyThroughDefaultTestCountry::first()->posts; + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + + $this->resetDefault(); + } + + public function testItLoadsARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $posts = HasManyThroughIntermediateTestCountry::first()->posts; + + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + } + + public function testEagerLoadingARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $posts = HasManyThroughIntermediateTestCountry::with('posts')->first()->posts; + + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + } + + public function testWhereHasOnARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $country = HasManyThroughIntermediateTestCountry::whereHas('posts', function ($query) { + $query->where('title', 'A title'); + })->get(); + + $this->assertCount(1, $country); + } + + public function testWithWhereHasOnARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $country = HasManyThroughIntermediateTestCountry::withWhereHas('posts', function ($query) { + $query->where('title', 'A title'); + })->get(); + + $this->assertCount(1, $country); + $this->assertTrue($country->first()->relationLoaded('posts')); + $this->assertEquals($country->first()->posts->pluck('title')->unique()->toArray(), ['A title']); + } + + public function testFindMethod() + { + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->createMany([ + ['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'title' => 'Another title', 'body' => 'Another body', 'email' => 'taylorotwell@gmail.com'], + ]); + + $country = HasManyThroughTestCountry::first(); + $post = $country->posts()->find(1); + + $this->assertNotNull($post); + $this->assertSame('A title', $post->title); + + $this->assertCount(2, $country->posts()->find([1, 2])); + $this->assertCount(2, $country->posts()->find(new Collection([1, 2]))); + } + + public function testFindManyMethod() + { + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->createMany([ + ['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'title' => 'Another title', 'body' => 'Another body', 'email' => 'taylorotwell@gmail.com'], + ]); + + $country = HasManyThroughTestCountry::first(); + + $this->assertCount(2, $country->posts()->findMany([1, 2])); + $this->assertCount(2, $country->posts()->findMany(new Collection([1, 2]))); + } + + public function testFirstOrFailThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\HasManyThroughTestPost].'); + + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']); + + HasManyThroughTestCountry::first()->posts()->firstOrFail(); + } + + public function testFindOrFailThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\HasManyThroughTestPost] 1'); + + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']); + + HasManyThroughTestCountry::first()->posts()->findOrFail(1); + } + + public function testFindOrFailWithManyThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\HasManyThroughTestPost] 1, 2'); + + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->create(['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com']); + + HasManyThroughTestCountry::first()->posts()->findOrFail([1, 2]); + } + + public function testFindOrFailWithManyUsingCollectionThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\HasManyThroughTestPost] 1, 2'); + + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->create(['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com']); + + HasManyThroughTestCountry::first()->posts()->findOrFail(new Collection([1, 2])); + } + + public function testFindOrMethod() + { + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->create(['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com']); + + $result = HasManyThroughTestCountry::first()->posts()->findOr(1, fn () => 'callback result'); + $this->assertInstanceOf(HasManyThroughTestPost::class, $result); + $this->assertSame(1, $result->id); + $this->assertSame('A title', $result->title); + + $result = HasManyThroughTestCountry::first()->posts()->findOr(1, ['posts.id'], fn () => 'callback result'); + $this->assertInstanceOf(HasManyThroughTestPost::class, $result); + $this->assertSame(1, $result->id); + $this->assertNull($result->title); + + $result = HasManyThroughTestCountry::first()->posts()->findOr(2, fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFindOrMethodWithMany() + { + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->createMany([ + ['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'title' => 'Another title', 'body' => 'Another body', 'email' => 'taylorotwell@gmail.com'], + ]); + + $result = HasManyThroughTestCountry::first()->posts()->findOr([1, 2], fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame(1, $result[0]->id); + $this->assertSame(2, $result[1]->id); + $this->assertSame('A title', $result[0]->title); + $this->assertSame('Another title', $result[1]->title); + + $result = HasManyThroughTestCountry::first()->posts()->findOr([1, 2], ['posts.id'], fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame(1, $result[0]->id); + $this->assertSame(2, $result[1]->id); + $this->assertNull($result[0]->title); + $this->assertNull($result[1]->title); + + $result = HasManyThroughTestCountry::first()->posts()->findOr([1, 2, 3], fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFindOrMethodWithManyUsingCollection() + { + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->createMany([ + ['id' => 1, 'title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'title' => 'Another title', 'body' => 'Another body', 'email' => 'taylorotwell@gmail.com'], + ]); + + $result = HasManyThroughTestCountry::first()->posts()->findOr(new Collection([1, 2]), fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame(1, $result[0]->id); + $this->assertSame(2, $result[1]->id); + $this->assertSame('A title', $result[0]->title); + $this->assertSame('Another title', $result[1]->title); + + $result = HasManyThroughTestCountry::first()->posts()->findOr(new Collection([1, 2]), ['posts.id'], fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame(1, $result[0]->id); + $this->assertSame(2, $result[1]->id); + $this->assertNull($result[0]->title); + $this->assertNull($result[1]->title); + + $result = HasManyThroughTestCountry::first()->posts()->findOr(new Collection([1, 2, 3]), fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFirstRetrievesFirstRecord() + { + $this->seedData(); + $post = HasManyThroughTestCountry::first()->posts()->first(); + + $this->assertNotNull($post); + $this->assertSame('A title', $post->title); + } + + public function testAllColumnsAreRetrievedByDefault() + { + $this->seedData(); + $post = HasManyThroughTestCountry::first()->posts()->first(); + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + } + + public function testOnlyProperColumnsAreSelectedIfProvided() + { + $this->seedData(); + $post = HasManyThroughTestCountry::first()->posts()->first(['title', 'body']); + + $this->assertEquals([ + 'title', + 'body', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + } + + public function testChunkReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $country = HasManyThroughTestCountry::find(2); + + $country->posts()->chunk(10, function ($postsChunk) { + $post = $postsChunk->first(); + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + }); + } + + public function testChunkById() + { + $this->seedData(); + $this->seedDataExtended(); + $country = HasManyThroughTestCountry::find(2); + + $i = 0; + $count = 0; + + $country->posts()->chunkById(2, function ($collection) use (&$i, &$count) { + $i++; + $count += $collection->count(); + }); + + $this->assertEquals(3, $i); + $this->assertEquals(6, $count); + } + + public function testCursorReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $country = HasManyThroughTestCountry::find(2); + + $posts = $country->posts()->cursor(); + + $this->assertInstanceOf(LazyCollection::class, $posts); + + foreach ($posts as $post) { + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + } + } + + public function testEachReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $country = HasManyThroughTestCountry::find(2); + + $country->posts()->each(function ($post) { + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + }); + } + + public function testEachByIdReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $country = HasManyThroughTestCountry::find(2); + + $country->posts()->eachById(function ($post) { + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + }); + } + + public function testLazyReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $country = HasManyThroughTestCountry::find(2); + + $country->posts()->lazy(10)->each(function ($post) { + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + }); + } + + public function testLazyById() + { + $this->seedData(); + $this->seedDataExtended(); + $country = HasManyThroughTestCountry::find(2); + + $i = 0; + + $country->posts()->lazyById(2)->each(function ($post) use (&$i, &$count) { + $i++; + + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + }); + + $this->assertEquals(6, $i); + } + + public function testIntermediateSoftDeletesAreIgnored() + { + $this->seedData(); + HasManyThroughSoftDeletesTestUser::first()->delete(); + + $posts = HasManyThroughSoftDeletesTestCountry::first()->posts; + + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + } + + public function testEagerLoadingLoadsRelatedModelsCorrectly() + { + $this->seedData(); + $country = HasManyThroughSoftDeletesTestCountry::with('posts')->first(); + + $this->assertSame('us', $country->shortname); + $this->assertSame('A title', $country->posts[0]->title); + $this->assertCount(2, $country->posts); + } + + /** + * Helpers... + */ + protected function seedData() + { + HasManyThroughTestCountry::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']) + ->posts()->createMany([ + ['title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com'], + ['title' => 'Another title', 'body' => 'Another body', 'email' => 'taylorotwell@gmail.com'], + ]); + } + + protected function seedDataExtended() + { + $country = HasManyThroughTestCountry::create(['id' => 2, 'name' => 'United Kingdom', 'shortname' => 'uk']); + $country->users()->create(['id' => 2, 'email' => 'example1@gmail.com', 'country_short' => 'uk']) + ->posts()->createMany([ + ['title' => 'Example1 title1', 'body' => 'Example1 body1', 'email' => 'example1post1@gmail.com'], + ['title' => 'Example1 title2', 'body' => 'Example1 body2', 'email' => 'example1post2@gmail.com'], + ]); + $country->users()->create(['id' => 3, 'email' => 'example2@gmail.com', 'country_short' => 'uk']) + ->posts()->createMany([ + ['title' => 'Example2 title1', 'body' => 'Example2 body1', 'email' => 'example2post1@gmail.com'], + ['title' => 'Example2 title2', 'body' => 'Example2 body2', 'email' => 'example2post2@gmail.com'], + ]); + $country->users()->create(['id' => 4, 'email' => 'example3@gmail.com', 'country_short' => 'uk']) + ->posts()->createMany([ + ['title' => 'Example3 title1', 'body' => 'Example3 body1', 'email' => 'example3post1@gmail.com'], + ['title' => 'Example3 title2', 'body' => 'Example3 body2', 'email' => 'example3post2@gmail.com'], + ]); + } + + /** + * Seed data for a default HasManyThrough setup. + */ + protected function seedDefaultData() + { + HasManyThroughDefaultTestCountry::create(['id' => 1, 'name' => 'United States of America']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com']) + ->posts()->createMany([ + ['title' => 'A title', 'body' => 'A body'], + ['title' => 'Another title', 'body' => 'Another body'], + ]); + } + + /** + * Drop the default tables. + */ + protected function resetDefault() + { + $this->schema()->drop('users_default'); + $this->schema()->drop('posts_default'); + $this->schema()->drop('countries_default'); + } + + /** + * Migrate tables for classes with a Laravel "default" HasManyThrough setup. + */ + protected function migrateDefault() + { + $this->schema()->create('users_default', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->unsignedInteger('has_many_through_default_test_country_id'); + $table->timestamps(); + }); + + $this->schema()->create('posts_default', function ($table) { + $table->increments('id'); + $table->integer('has_many_through_default_test_user_id'); + $table->string('title'); + $table->text('body'); + $table->timestamps(); + }); + + $this->schema()->create('countries_default', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class HasManyThroughTestUser extends Eloquent +{ + protected ?string $table = 'users'; + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(HasManyThroughTestPost::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class HasManyThroughTestPost extends Eloquent +{ + protected ?string $table = 'posts'; + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(HasManyThroughTestUser::class, 'user_id'); + } +} + +class HasManyThroughTestCountry extends Eloquent +{ + protected ?string $table = 'countries'; + protected array $guarded = []; + + public function posts() + { + return $this->hasManyThrough(HasManyThroughTestPost::class, HasManyThroughTestUser::class, 'country_id', 'user_id'); + } + + public function users() + { + return $this->hasMany(HasManyThroughTestUser::class, 'country_id'); + } +} + +/** + * Eloquent Models... + */ +class HasManyThroughDefaultTestUser extends Eloquent +{ + protected ?string $table = 'users_default'; + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(HasManyThroughDefaultTestPost::class); + } +} + +/** + * Eloquent Models... + */ +class HasManyThroughDefaultTestPost extends Eloquent +{ + protected ?string $table = 'posts_default'; + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(HasManyThroughDefaultTestUser::class); + } +} + +class HasManyThroughDefaultTestCountry extends Eloquent +{ + protected ?string $table = 'countries_default'; + protected array $guarded = []; + + public function posts() + { + return $this->hasManyThrough(HasManyThroughDefaultTestPost::class, HasManyThroughDefaultTestUser::class); + } + + public function users() + { + return $this->hasMany(HasManyThroughDefaultTestUser::class); + } +} + +class HasManyThroughIntermediateTestCountry extends Eloquent +{ + protected ?string $table = 'countries'; + protected array $guarded = []; + + public function posts() + { + return $this->hasManyThrough(HasManyThroughTestPost::class, HasManyThroughTestUser::class, 'country_short', 'email', 'shortname', 'email'); + } + + public function users() + { + return $this->hasMany(HasManyThroughTestUser::class, 'country_id'); + } +} + +class HasManyThroughSoftDeletesTestUser extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'users'; + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(HasManyThroughSoftDeletesTestPost::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class HasManyThroughSoftDeletesTestPost extends Eloquent +{ + protected ?string $table = 'posts'; + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(HasManyThroughSoftDeletesTestUser::class, 'user_id'); + } +} + +class HasManyThroughSoftDeletesTestCountry extends Eloquent +{ + protected ?string $table = 'countries'; + protected array $guarded = []; + + public function posts() + { + return $this->hasManyThrough(HasManyThroughSoftDeletesTestPost::class, HasManyThroughTestUser::class, 'country_id', 'user_id'); + } + + public function users() + { + return $this->hasMany(HasManyThroughSoftDeletesTestUser::class, 'country_id'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneOfManyTest.php new file mode 100755 index 000000000..4b65ec231 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneOfManyTest.php @@ -0,0 +1,707 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + }); + + $this->schema()->create('logins', function ($table) { + $table->increments('id'); + $table->foreignId('user_id'); + $table->dateTime('deleted_at')->nullable(); + }); + + $this->schema()->create('states', function ($table) { + $table->increments('id'); + $table->string('state'); + $table->string('type'); + $table->foreignId('user_id'); + $table->timestamps(); + }); + + $this->schema()->create('prices', function ($table) { + $table->increments('id'); + $table->dateTime('published_at'); + $table->foreignId('user_id'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('logins'); + $this->schema()->drop('states'); + $this->schema()->drop('prices'); + + parent::tearDown(); + } + + public function testItGuessesRelationName() + { + $user = HasOneOfManyTestUser::make(); + $this->assertSame('latest_login', $user->latest_login()->getRelationName()); + } + + public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationName() + { + $model = HasOneOfManyTestModel::make(); + $this->assertSame('logins_of_many', $model->logins()->getRelationName()); + } + + public function testRelationNameCanBeSet() + { + $user = HasOneOfManyTestUser::create(); + + // Using "ofMany" + $relation = $user->latest_login()->ofMany('id', 'max', 'foo'); + $this->assertSame('foo', $relation->getRelationName()); + + // Using "latestOfMAny" + $relation = $user->latest_login()->latestOfMAny('id', 'bar'); + $this->assertSame('bar', $relation->getRelationName()); + + // Using "oldestOfMAny" + $relation = $user->latest_login()->oldestOfMAny('id', 'baz'); + $this->assertSame('baz', $relation->getRelationName()); + } + + public function testCorrectLatestOfManyQuery(): void + { + $user = HasOneOfManyTestUser::create(); + $relation = $user->latest_login(); + $this->assertSame('select "logins".* from "logins" inner join (select MAX("logins"."id") as "id_aggregate", "logins"."user_id" from "logins" where "logins"."user_id" = ? and "logins"."user_id" is not null group by "logins"."user_id") as "latest_login" on "latest_login"."id_aggregate" = "logins"."id" and "latest_login"."user_id" = "logins"."user_id" where "logins"."user_id" = ? and "logins"."user_id" is not null', $relation->getQuery()->toSql()); + } + + public function testEagerLoadingAppliesConstraintsToInnerJoinSubQuery() + { + $user = HasOneOfManyTestUser::create(); + $relation = $user->latest_login(); + $relation->addEagerConstraints([$user]); + $this->assertSame('select MAX("logins"."id") as "id_aggregate", "logins"."user_id" from "logins" where "logins"."user_id" = ? and "logins"."user_id" is not null and "logins"."user_id" in (1) group by "logins"."user_id"', $relation->getOneOfManySubQuery()->toSql()); + } + + public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalScope() + { + HasOneOfManyTestLogin::addGlobalScope('test', function ($query) { + $query->orderBy('id'); + }); + + $user = HasOneOfManyTestUser::create(); + $relation = $user->latest_login_without_global_scope(); + $relation->addEagerConstraints([$user]); + $this->assertSame('select "logins".* from "logins" inner join (select MAX("logins"."id") as "id_aggregate", "logins"."user_id" from "logins" where "logins"."user_id" = ? and "logins"."user_id" is not null and "logins"."user_id" in (1) group by "logins"."user_id") as "latestOfMany" on "latestOfMany"."id_aggregate" = "logins"."id" and "latestOfMany"."user_id" = "logins"."user_id" where "logins"."user_id" = ? and "logins"."user_id" is not null', $relation->getQuery()->toSql()); + + HasOneOfManyTestLogin::addGlobalScope('test', function ($query) { + }); + } + + public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalScopeWithComplexQuery() + { + HasOneOfManyTestPrice::addGlobalScope('test', function ($query) { + $query->orderBy('id'); + }); + + $user = HasOneOfManyTestUser::create(); + $relation = $user->price_without_global_scope(); + $this->assertSame('select "prices".* from "prices" inner join (select max("prices"."id") as "id_aggregate", min("prices"."published_at") as "published_at_aggregate", "prices"."user_id" from "prices" inner join (select max("prices"."published_at") as "published_at_aggregate", "prices"."user_id" from "prices" where "published_at" < ? and "prices"."user_id" = ? and "prices"."user_id" is not null group by "prices"."user_id") as "price_without_global_scope" on "price_without_global_scope"."published_at_aggregate" = "prices"."published_at" and "price_without_global_scope"."user_id" = "prices"."user_id" where "published_at" < ? group by "prices"."user_id") as "price_without_global_scope" on "price_without_global_scope"."id_aggregate" = "prices"."id" and "price_without_global_scope"."published_at_aggregate" = "prices"."published_at" and "price_without_global_scope"."user_id" = "prices"."user_id" where "prices"."user_id" = ? and "prices"."user_id" is not null', $relation->getQuery()->toSql()); + + HasOneOfManyTestPrice::addGlobalScope('test', function ($query) { + }); + } + + public function testQualifyingSubSelectColumn() + { + $user = HasOneOfManyTestUser::create(); + $this->assertSame('latest_login.id', $user->latest_login()->qualifySubSelectColumn('id')); + } + + public function testItFailsWhenUsingInvalidAggregate() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid aggregate [count] used within ofMany relation. Available aggregates: MIN, MAX'); + $user = HasOneOfManyTestUser::make(); + $user->latest_login_with_invalid_aggregate(); + } + + public function testItGetsCorrectResults() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $result = $user->latest_login()->getResults(); + $this->assertNotNull($result); + $this->assertSame($latestLogin->id, $result->id); + } + + public function testResultDoesNotHaveAggregateColumn() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + + $result = $user->latest_login()->getResults(); + $this->assertNotNull($result); + $this->assertFalse(isset($result->id_aggregate)); + } + + public function testItGetsCorrectResultsUsingShortcutMethod() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $result = $user->latest_login_with_shortcut()->getResults(); + $this->assertNotNull($result); + $this->assertSame($latestLogin->id, $result->id); + } + + public function testItGetsCorrectResultsUsingShortcutReceivingMultipleColumnsMethod() + { + $user = HasOneOfManyTestUser::create(); + $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $result = $user->price_with_shortcut()->getResults(); + $this->assertNotNull($result); + $this->assertSame($price->id, $result->id); + } + + public function testKeyIsAddedToAggregatesWhenMissing() + { + $user = HasOneOfManyTestUser::create(); + $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $result = $user->price_without_key_in_aggregates()->getResults(); + $this->assertNotNull($result); + $this->assertSame($price->id, $result->id); + } + + public function testItGetsWithConstraintsCorrectResults() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $user->logins()->create(); + + $result = $user->latest_login()->whereKey($previousLogin->getKey())->getResults(); + $this->assertNull($result); + } + + public function testItEagerLoadsCorrectModels() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $user = HasOneOfManyTestUser::with('latest_login')->first(); + + $this->assertTrue($user->relationLoaded('latest_login')); + $this->assertSame($latestLogin->id, $user->latest_login->id); + } + + public function testItJoinsOtherTableInSubQuery() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + + $this->assertNull($user->latest_login_with_foo_state); + + $user->unsetRelation('latest_login_with_foo_state'); + $user->states()->create([ + 'type' => 'foo', + 'state' => 'draft', + ]); + + $this->assertNotNull($user->latest_login_with_foo_state); + } + + public function testHasNested() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $found = HasOneOfManyTestUser::whereHas('latest_login', function ($query) use ($latestLogin) { + $query->where('logins.id', $latestLogin->id); + })->exists(); + $this->assertTrue($found); + + $found = HasOneOfManyTestUser::whereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + $this->assertFalse($found); + } + + public function testWithHasNested() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $found = HasOneOfManyTestUser::withWhereHas('latest_login', function ($query) use ($latestLogin) { + $query->where('logins.id', $latestLogin->id); + })->first(); + + $this->assertTrue((bool) $found); + $this->assertTrue($found->relationLoaded('latest_login')); + $this->assertEquals($found->latest_login->id, $latestLogin->id); + + $found = HasOneOfManyTestUser::withWhereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + + $this->assertFalse($found); + } + + public function testHasCount() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $user->logins()->create(); + + $user = HasOneOfManyTestUser::withCount('latest_login')->first(); + $this->assertEquals(1, $user->latest_login_count); + } + + public function testExists() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $this->assertFalse($user->latest_login()->whereKey($previousLogin->getKey())->exists()); + $this->assertTrue($user->latest_login()->whereKey($latestLogin->getKey())->exists()); + } + + public function testIsMethod() + { + $user = HasOneOfManyTestUser::create(); + $login1 = $user->latest_login()->create(); + $login2 = $user->latest_login()->create(); + + $this->assertFalse($user->latest_login()->is($login1)); + $this->assertTrue($user->latest_login()->is($login2)); + } + + public function testIsNotMethod() + { + $user = HasOneOfManyTestUser::create(); + $login1 = $user->latest_login()->create(); + $login2 = $user->latest_login()->create(); + + $this->assertTrue($user->latest_login()->isNot($login1)); + $this->assertFalse($user->latest_login()->isNot($login2)); + } + + public function testGet() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $latestLogins = $user->latest_login()->get(); + $this->assertCount(1, $latestLogins); + $this->assertSame($latestLogin->id, $latestLogins->first()->id); + + $latestLogins = $user->latest_login()->whereKey($previousLogin->getKey())->get(); + $this->assertCount(0, $latestLogins); + } + + public function testCount() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $user->logins()->create(); + + $this->assertSame(1, $user->latest_login()->count()); + } + + public function testAggregate() + { + $user = HasOneOfManyTestUser::create(); + $firstLogin = $user->logins()->create(); + $user->logins()->create(); + + $user = HasOneOfManyTestUser::first(); + $this->assertSame($firstLogin->id, $user->first_login->id); + } + + public function testJoinConstraints() + { + $user = HasOneOfManyTestUser::create(); + $user->states()->create([ + 'type' => 'foo', + 'state' => 'draft', + ]); + $currentForState = $user->states()->create([ + 'type' => 'foo', + 'state' => 'active', + ]); + $user->states()->create([ + 'type' => 'bar', + 'state' => 'baz', + ]); + + $user = HasOneOfManyTestUser::first(); + $this->assertSame($currentForState->id, $user->foo_state->id); + } + + public function testMultipleAggregates() + { + $user = HasOneOfManyTestUser::create(); + + $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $user = HasOneOfManyTestUser::first(); + $this->assertSame($price->id, $user->price->id); + } + + public function testEagerLoadingWithMultipleAggregates() + { + $user1 = HasOneOfManyTestUser::create(); + $user2 = HasOneOfManyTestUser::create(); + + $user1->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $user1Price = $user1->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $user1->prices()->create([ + 'published_at' => '2021-04-01 00:00:00', + ]); + + $user2Price = $user2->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $user2->prices()->create([ + 'published_at' => '2021-04-01 00:00:00', + ]); + + $users = HasOneOfManyTestUser::with('price')->get(); + + $this->assertNotNull($users[0]->price); + $this->assertSame($user1Price->id, $users[0]->price->id); + + $this->assertNotNull($users[1]->price); + $this->assertSame($user2Price->id, $users[1]->price->id); + } + + public function testWithExists() + { + $user = HasOneOfManyTestUser::create(); + + $user = HasOneOfManyTestUser::withExists('latest_login')->first(); + $this->assertFalse($user->latest_login_exists); + + $user->logins()->create(); + $user = HasOneOfManyTestUser::withExists('latest_login')->first(); + $this->assertTrue($user->latest_login_exists); + } + + public function testWithExistsWithConstraintsInJoinSubSelect() + { + $user = HasOneOfManyTestUser::create(); + + $user = HasOneOfManyTestUser::withExists('foo_state')->first(); + + $this->assertFalse($user->foo_state_exists); + + $user->states()->create([ + 'type' => 'foo', + 'state' => 'bar', + ]); + $user = HasOneOfManyTestUser::withExists('foo_state')->first(); + $this->assertTrue($user->foo_state_exists); + } + + public function testWithSoftDeletes() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $user->latest_login_with_soft_deletes; + $this->assertNotNull($user->latest_login_with_soft_deletes); + } + + public function testWithConstraintNotInAggregate() + { + $user = HasOneOfManyTestUser::create(); + + $previousFoo = $user->states()->create([ + 'type' => 'foo', + 'state' => 'bar', + 'updated_at' => '2020-01-01 00:00:00', + ]); + $newFoo = $user->states()->create([ + 'type' => 'foo', + 'state' => 'active', + 'updated_at' => '2021-01-01 12:00:00', + ]); + $newBar = $user->states()->create([ + 'type' => 'bar', + 'state' => 'active', + 'updated_at' => '2021-01-01 12:00:00', + ]); + + $this->assertSame($newFoo->id, $user->last_updated_foo_state->id); + } + + public function testItGetsCorrectResultUsingAtLeastTwoAggregatesDistinctFromId() + { + $user = HasOneOfManyTestUser::create(); + + $expectedState = $user->states()->create([ + 'state' => 'state', + 'type' => 'type', + 'created_at' => '2023-01-01', + 'updated_at' => '2023-01-03', + ]); + + $user->states()->create([ + 'state' => 'state', + 'type' => 'type', + 'created_at' => '2023-01-01', + 'updated_at' => '2023-01-02', + ]); + + $this->assertSame($user->latest_updated_latest_created_state->id, $expectedState->id); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class HasOneOfManyTestUser extends Eloquent +{ + protected ?string $table = 'users'; + protected array $guarded = []; + public bool $timestamps = false; + + public function logins() + { + return $this->hasMany(HasOneOfManyTestLogin::class, 'user_id'); + } + + public function latest_login() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany(); + } + + public function latest_login_with_soft_deletes() + { + return $this->hasOne(HasOneOfManyTestLoginWithSoftDeletes::class, 'user_id')->ofMany(); + } + + public function latest_login_with_shortcut() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->latestOfMany(); + } + + public function latest_login_with_invalid_aggregate() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany('id', 'count'); + } + + public function latest_login_without_global_scope() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->withoutGlobalScopes()->latestOfMany(); + } + + public function first_login() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany('id', 'min'); + } + + public function latest_login_with_foo_state() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany( + ['id' => 'max'], + function ($query) { + $query->join('states', 'states.user_id', 'logins.user_id') + ->where('states.type', 'foo'); + } + ); + } + + public function states() + { + return $this->hasMany(HasOneOfManyTestState::class, 'user_id'); + } + + public function foo_state() + { + return $this->hasOne(HasOneOfManyTestState::class, 'user_id')->ofMany( + [], // should automatically add 'id' => 'max' + function ($q) { + $q->where('type', 'foo'); + } + ); + } + + public function last_updated_foo_state() + { + return $this->hasOne(HasOneOfManyTestState::class, 'user_id')->ofMany([ + 'updated_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('type', 'foo'); + }); + } + + public function prices() + { + return $this->hasMany(HasOneOfManyTestPrice::class, 'user_id'); + } + + public function price() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->ofMany([ + 'published_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('published_at', '<', now()); + }); + } + + public function price_without_key_in_aggregates() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->ofMany(['published_at' => 'MAX']); + } + + public function price_with_shortcut() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->latestOfMany(['published_at', 'id']); + } + + public function price_without_global_scope() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->withoutGlobalScopes()->ofMany([ + 'published_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('published_at', '<', now()); + }); + } + + public function latest_updated_latest_created_state() + { + return $this->hasOne(HasOneOfManyTestState::class, 'user_id')->ofMany([ + 'updated_at' => 'max', + 'created_at' => 'max', + ]); + } +} + +class HasOneOfManyTestModel extends Eloquent +{ + public function logins() + { + return $this->hasOne(HasOneOfManyTestLogin::class)->ofMany(); + } +} + +class HasOneOfManyTestLogin extends Eloquent +{ + protected ?string $table = 'logins'; + protected array $guarded = []; + public bool $timestamps = false; +} + +class HasOneOfManyTestLoginWithSoftDeletes extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'logins'; + protected array $guarded = []; + public bool $timestamps = false; +} + +class HasOneOfManyTestState extends Eloquent +{ + protected ?string $table = 'states'; + protected array $guarded = []; + public bool $timestamps = true; + protected array $fillable = ['type', 'state', 'updated_at']; +} + +class HasOneOfManyTestPrice extends Eloquent +{ + protected ?string $table = 'prices'; + protected array $guarded = []; + public bool $timestamps = false; + protected array $fillable = ['published_at']; + protected array $casts = ['published_at' => 'datetime']; +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesPendingTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesPendingTest.php new file mode 100644 index 000000000..b624481cb --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesPendingTest.php @@ -0,0 +1,307 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + } + + public function testHasManyAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedPendingAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value], asConditions: false); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testHasOneAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasOne(RelatedPendingAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value], asConditions: false); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testMorphManyAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->morphMany(RelatedPendingAttributesModel::class, 'relatable') + ->withAttributes([$key => $value], asConditions: false); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertSame($value, $relatedModel->$key); + } + + public function testMorphOneAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->morphOne(RelatedPendingAttributesModel::class, 'relatable') + ->withAttributes([$key => $value], asConditions: false); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertSame($value, $relatedModel->$key); + } + + public function testPendingAttributesCanBeOverridden(): void + { + $key = 'a key'; + $defaultValue = 'a value'; + $value = 'the value'; + + $parent = new RelatedPendingAttributesModel; + + $relationship = $parent + ->hasMany(RelatedPendingAttributesModel::class, 'relatable') + ->withAttributes([$key => $defaultValue], asConditions: false); + + $relatedModel = $relationship->make([$key => $value]); + + $this->assertSame($value, $relatedModel->$key); + } + + public function testQueryingDoesNotBreakWither(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedPendingAttributesModel::class, 'parent_id') + ->where($key, $value) + ->withAttributes([$key => $value], asConditions: false); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testAttributesCanBeAppended(): void + { + $parent = new RelatedPendingAttributesModel; + + $relationship = $parent + ->hasMany(RelatedPendingAttributesModel::class, 'parent_id') + ->withAttributes(['a' => 'A'], asConditions: false) + ->withAttributes(['b' => 'B'], asConditions: false) + ->withAttributes(['a' => 'AA'], asConditions: false); + + $relatedModel = $relationship->make([ + 'b' => 'BB', + 'c' => 'C', + ]); + + $this->assertSame('AA', $relatedModel->a); + $this->assertSame('BB', $relatedModel->b); + $this->assertSame('C', $relatedModel->c); + } + + public function testSingleAttributeApi(): void + { + $parent = new RelatedPendingAttributesModel; + $key = 'attr'; + $value = 'Value'; + + $relationship = $parent + ->hasMany(RelatedPendingAttributesModel::class, 'parent_id') + ->withAttributes($key, $value, asConditions: false); + + $relatedModel = $relationship->make(); + + $this->assertSame($value, $relatedModel->$key); + } + + public function testWheresAreNotSet(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedPendingAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value], asConditions: false); + + $wheres = $relationship->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => $parent->qualifyColumn('parent_id'), + 'operator' => '=', + 'value' => $parentId, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'NotNull', + 'column' => $parent->qualifyColumn('parent_id'), + 'boolean' => 'and', + ], $wheres); + + // Ensure no other wheres exist + $this->assertCount(2, $wheres); + } + + public function testNullValueIsAccepted(): void + { + $parentId = 123; + $key = 'a key'; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedPendingAttributesModel::class, 'parent_id') + ->withAttributes([$key => null], asConditions: false); + + $wheres = $relationship->toBase()->wheres; + $relatedModel = $relationship->make(); + + $this->assertNull($relatedModel->$key); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => $parent->qualifyColumn('parent_id'), + 'operator' => '=', + 'value' => $parentId, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'NotNull', + 'column' => $parent->qualifyColumn('parent_id'), + 'boolean' => 'and', + ], $wheres); + + // Ensure no other wheres exist + $this->assertCount(2, $wheres); + } + + public function testOneKeepsAttributesFromHasMany(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedPendingAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value], asConditions: false) + ->one(); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testOneKeepsAttributesFromMorphMany(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->morphMany(RelatedPendingAttributesModel::class, 'relatable') + ->withAttributes([$key => $value], asConditions: false) + ->one(); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertSame($value, $relatedModel->$key); + } + + public function testHasManyAddsCastedAttributes(): void + { + $parentId = 123; + + $parent = new RelatedPendingAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedPendingAttributesModel::class, 'parent_id') + ->withAttributes(['is_admin' => 1], asConditions: false); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame(true, $relatedModel->is_admin); + } +} + +class RelatedPendingAttributesModel extends Model +{ + protected $guarded = []; + + protected $casts = [ + 'is_admin' => 'boolean', + ]; +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesTest.php new file mode 100755 index 000000000..093006967 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesTest.php @@ -0,0 +1,296 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + } + + public function testHasManyAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testHasOneAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasOne(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testMorphManyAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->morphMany(RelatedWithAttributesModel::class, 'relatable') + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertSame($value, $relatedModel->$key); + } + + public function testMorphOneAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->morphOne(RelatedWithAttributesModel::class, 'relatable') + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertSame($value, $relatedModel->$key); + } + + public function testWithAttributesCanBeOverridden(): void + { + $key = 'a key'; + $defaultValue = 'a value'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'relatable') + ->withAttributes([$key => $defaultValue]); + + $relatedModel = $relationship->make([$key => $value]); + + $this->assertSame($value, $relatedModel->$key); + } + + public function testQueryingDoesNotBreakWither(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->where($key, $value) + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testAttributesCanBeAppended(): void + { + $parent = new RelatedWithAttributesModel; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes(['a' => 'A']) + ->withAttributes(['b' => 'B']) + ->withAttributes(['a' => 'AA']); + + $relatedModel = $relationship->make([ + 'b' => 'BB', + 'c' => 'C', + ]); + + $this->assertSame('AA', $relatedModel->a); + $this->assertSame('BB', $relatedModel->b); + $this->assertSame('C', $relatedModel->c); + } + + public function testSingleAttributeApi(): void + { + $parent = new RelatedWithAttributesModel; + $key = 'attr'; + $value = 'Value'; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes($key, $value); + + $relatedModel = $relationship->make(); + + $this->assertSame($value, $relatedModel->$key); + } + + public function testWheresAreSet(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value]); + + $wheres = $relationship->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'related_with_attributes_models.'.$key, + 'operator' => '=', + 'value' => $value, + 'boolean' => 'and', + ], $wheres); + + // Ensure this doesn't break the default where either. + $this->assertContains([ + 'type' => 'Basic', + 'column' => $parent->qualifyColumn('parent_id'), + 'operator' => '=', + 'value' => $parentId, + 'boolean' => 'and', + ], $wheres); + } + + public function testNullValueIsAccepted(): void + { + $parentId = 123; + $key = 'a key'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => null]); + + $wheres = $relationship->toBase()->wheres; + $relatedModel = $relationship->make(); + + $this->assertNull($relatedModel->$key); + + $this->assertContains([ + 'type' => 'Null', + 'column' => 'related_with_attributes_models.'.$key, + 'boolean' => 'and', + ], $wheres); + } + + public function testOneKeepsAttributesFromHasMany(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value]) + ->one(); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testOneKeepsAttributesFromMorphMany(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->morphMany(RelatedWithAttributesModel::class, 'relatable') + ->withAttributes([$key => $value]) + ->one(); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertSame($value, $relatedModel->$key); + } + + public function testHasManyAddsCastedAttributes(): void + { + $parentId = 123; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes(['is_admin' => 1]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame(true, $relatedModel->is_admin); + } +} + +class RelatedWithAttributesModel extends Model +{ + protected $guarded = []; + + protected $casts = [ + 'is_admin' => 'boolean', + ]; +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneTest.php new file mode 100755 index 000000000..14e9f3f9e --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneTest.php @@ -0,0 +1,338 @@ +getRelation()->withDefault(); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + // Use andReturnSelf() to satisfy static return type of newInstance() + $this->related->shouldReceive('newInstance')->once()->andReturnSelf(); + $this->related->shouldReceive('setAttribute')->with('foreign_key', 1)->once()->andReturnSelf(); + + $result = $relation->getResults(); + $this->assertSame($this->related, $result); + } + + public function testHasOneWithDynamicDefault() + { + $relation = $this->getRelation()->withDefault(function ($newModel) { + $newModel->username = 'taylor'; + }); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + // Use andReturnSelf() to satisfy static return type of newInstance() + $this->related->shouldReceive('newInstance')->once()->andReturnSelf(); + $this->related->shouldReceive('setAttribute')->with('foreign_key', 1)->once()->andReturnSelf(); + + $result = $relation->getResults(); + $this->assertSame($this->related, $result); + $this->assertSame('taylor', $result->username); + } + + public function testHasOneWithDynamicDefaultUseParentModel() + { + $relation = $this->getRelation()->withDefault(function ($newModel, $parentModel) { + $newModel->username = $parentModel->username; + }); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + // Use andReturnSelf() to satisfy static return type of newInstance() + $this->related->shouldReceive('newInstance')->once()->andReturnSelf(); + $this->related->shouldReceive('setAttribute')->with('foreign_key', 1)->once()->andReturnSelf(); + + $result = $relation->getResults(); + $this->assertSame($this->related, $result); + $this->assertSame('taylor', $result->username); + } + + public function testHasOneWithArrayDefault() + { + $attributes = ['username' => 'taylor']; + + $relation = $this->getRelation()->withDefault($attributes); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + // Use andReturnSelf() to satisfy static return type of newInstance() + $this->related->shouldReceive('newInstance')->once()->andReturnSelf(); + $this->related->shouldReceive('setAttribute')->with('foreign_key', 1)->once()->andReturnSelf(); + + $result = $relation->getResults(); + $this->assertSame($this->related, $result); + $this->assertSame('taylor', $result->username); + } + + public function testMakeMethodDoesNotSaveNewModel() + { + $relation = $this->getRelation(); + // Use andReturnSelf() to satisfy static return type of newInstance() + $this->related->shouldReceive('newInstance')->once()->with(['name' => 'taylor'])->andReturnSelf(); + $this->related->shouldReceive('setAttribute')->once()->with('foreign_key', 1)->andReturnSelf(); + $this->related->shouldReceive('save')->never(); + + $this->assertEquals($this->related, $relation->make(['name' => 'taylor'])); + } + + public function testSaveMethodSetsForeignKeyOnModel() + { + $relation = $this->getRelation(); + $mockModel = $this->getMockBuilder(Model::class)->onlyMethods(['save'])->getMock(); + $mockModel->expects($this->once())->method('save')->willReturn(true); + $result = $relation->save($mockModel); + + $attributes = $result->getAttributes(); + $this->assertEquals(1, $attributes['foreign_key']); + } + + public function testCreateMethodProperlyCreatesNewModel() + { + $relation = $this->getRelation(); + // Use andReturnSelf() to satisfy static return type of newInstance() + $this->related->shouldReceive('newInstance')->once()->with(['name' => 'taylor'])->andReturnSelf(); + $this->related->shouldReceive('setAttribute')->once()->with('foreign_key', 1)->andReturnSelf(); + $this->related->shouldReceive('save')->once()->andReturn(true); + + $this->assertEquals($this->related, $relation->create(['name' => 'taylor'])); + } + + public function testForceCreateMethodProperlyCreatesNewModel() + { + $relation = $this->getRelation(); + $attributes = ['name' => 'taylor', $relation->getForeignKeyName() => $relation->getParentKey()]; + + $created = m::mock(Model::class); + $created->shouldReceive('getAttribute')->with($relation->getForeignKeyName())->andReturn($relation->getParentKey()); + + $relation->getRelated()->shouldReceive('forceCreate')->once()->with($attributes)->andReturn($created); + + $this->assertEquals($created, $relation->forceCreate(['name' => 'taylor'])); + $this->assertEquals(1, $created->getAttribute('foreign_key')); + } + + public function testRelationIsProperlyInitialized() + { + $relation = $this->getRelation(); + $model = m::mock(Model::class); + $model->shouldReceive('setRelation')->once()->with('foo', null); + $models = $relation->initRelation([$model], 'foo'); + + $this->assertEquals([$model], $models); + } + + public function testEagerConstraintsAreProperlyAdded() + { + $relation = $this->getRelation(); + $relation->getParent()->shouldReceive('getKeyName')->once()->andReturn('id'); + $relation->getParent()->shouldReceive('getKeyType')->once()->andReturn('int'); + $relation->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('table.foreign_key', [1, 2]); + $model1 = new EloquentHasOneModelStub; + $model1->id = 1; + $model2 = new EloquentHasOneModelStub; + $model2->id = 2; + $relation->addEagerConstraints([$model1, $model2]); + } + + public function testModelsAreProperlyMatchedToParents() + { + $relation = $this->getRelation(); + + $result1 = new EloquentHasOneModelStub; + $result1->foreign_key = 1; + $result2 = new EloquentHasOneModelStub; + $result2->foreign_key = 2; + $result3 = new EloquentHasOneModelStub; + $result3->foreign_key = new class + { + public function __toString() + { + return '4'; + } + }; + + $model1 = new EloquentHasOneModelStub; + $model1->id = 1; + $model2 = new EloquentHasOneModelStub; + $model2->id = 2; + $model3 = new EloquentHasOneModelStub; + $model3->id = 3; + $model4 = new EloquentHasOneModelStub; + $model4->id = 4; + + $models = $relation->match([$model1, $model2, $model3, $model4], new Collection([$result1, $result2, $result3]), 'foo'); + + $this->assertEquals(1, $models[0]->foo->foreign_key); + $this->assertEquals(2, $models[1]->foo->foreign_key); + $this->assertNull($models[2]->foo); + $this->assertSame('4', (string) $models[3]->foo->foreign_key); + } + + public function testRelationCountQueryCanBeBuilt() + { + $relation = $this->getRelation(); + $builder = m::mock(Builder::class); + + $baseQuery = m::mock(BaseBuilder::class); + $baseQuery->from = 'one'; + $parentQuery = m::mock(BaseBuilder::class); + $parentQuery->from = 'two'; + + $builder->shouldReceive('getQuery')->once()->andReturn($baseQuery); + $builder->shouldReceive('getQuery')->once()->andReturn($parentQuery); + + $builder->shouldReceive('select')->once()->with(m::type(Expression::class))->andReturnSelf(); + $relation->getParent()->shouldReceive('qualifyColumn')->andReturn('table.id'); + // Return $builder (Eloquent Builder) to satisfy return type + $builder->shouldReceive('whereColumn')->once()->with('table.id', '=', 'table.foreign_key')->andReturnSelf(); + // setBindings is called on the Eloquent Builder, which forwards to base query + $builder->shouldReceive('setBindings')->once()->with([], 'select')->andReturnSelf(); + + $relation->getRelationExistenceCountQuery($builder, $builder); + } + + public function testIsNotNull() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->never(); + $this->related->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is(null)); + } + + public function testIsModel() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->once()->andReturn('table'); + $this->related->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithStringRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->once()->andReturn('table'); + $this->related->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('1'); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsNotModelWithNullRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->never(); + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(null); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->never(); + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(2); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherTable() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->once()->andReturn('table'); + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table.two'); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherConnection() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getTable')->once()->andReturn('table'); + $this->related->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection.two'); + + $this->assertFalse($relation->is($model)); + } + + protected function getRelation() + { + $this->builder = m::mock(Builder::class); + $this->builder->shouldReceive('whereNotNull')->with('table.foreign_key'); + $this->builder->shouldReceive('where')->with('table.foreign_key', '=', 1); + // Use partial mock so real Model methods work (setAttribute, forceFill, etc.) + $this->related = m::mock(Model::class)->makePartial(); + $this->builder->shouldReceive('getModel')->andReturn($this->related); + $this->parent = m::mock(Model::class); + $this->parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $this->parent->shouldReceive('getAttribute')->with('username')->andReturn('taylor'); + $this->parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); + $this->parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + $this->parent->shouldReceive('newQueryWithoutScopes')->andReturn($this->builder); + + return new HasOne($this->builder, $this->parent, 'table.foreign_key', 'id'); + } +} + +class EloquentHasOneModelStub extends Model +{ + public mixed $foreign_key = 'foreign.value'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneThroughIntegrationTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneThroughIntegrationTest.php new file mode 100644 index 000000000..2a8f7649f --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneThroughIntegrationTest.php @@ -0,0 +1,525 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->unsignedInteger('position_id')->unique()->nullable(); + $table->string('position_short'); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->schema()->create('contracts', function ($table) { + $table->increments('id'); + $table->integer('user_id')->unique(); + $table->string('title'); + $table->text('body'); + $table->string('email'); + $table->timestamps(); + }); + + $this->schema()->create('positions', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->string('shortname'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('contracts'); + $this->schema()->drop('positions'); + + parent::tearDown(); + } + + public function testItLoadsAHasOneThroughRelationWithCustomKeys() + { + $this->seedData(); + $contract = HasOneThroughTestPosition::first()->contract; + + $this->assertSame('A title', $contract->title); + } + + public function testItLoadsADefaultHasOneThroughRelation() + { + $this->migrateDefault(); + $this->seedDefaultData(); + + $contract = HasOneThroughDefaultTestPosition::first()->contract; + $this->assertSame('A title', $contract->title); + $this->assertArrayNotHasKey('email', $contract->getAttributes()); + + $this->resetDefault(); + } + + public function testItLoadsARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $contract = HasOneThroughIntermediateTestPosition::first()->contract; + + $this->assertSame('A title', $contract->title); + } + + public function testEagerLoadingARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $contract = HasOneThroughIntermediateTestPosition::with('contract')->first()->contract; + + $this->assertSame('A title', $contract->title); + } + + public function testWhereHasOnARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $position = HasOneThroughIntermediateTestPosition::whereHas('contract', function ($query) { + $query->where('title', 'A title'); + })->get(); + + $this->assertCount(1, $position); + } + + public function testWithWhereHasOnARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $position = HasOneThroughIntermediateTestPosition::withWhereHas('contract', function ($query) { + $query->where('title', 'A title'); + })->get(); + + $this->assertCount(1, $position); + $this->assertTrue($position->first()->relationLoaded('contract')); + $this->assertEquals($position->first()->contract->pluck('title')->unique()->toArray(), ['A title']); + } + + public function testFirstOrFailThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\HasOneThroughTestContract].'); + + HasOneThroughTestPosition::create(['id' => 1, 'name' => 'President', 'shortname' => 'ps']) + ->user()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'position_short' => 'ps']); + + HasOneThroughTestPosition::first()->contract()->firstOrFail(); + } + + public function testFindOrFailThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + + HasOneThroughTestPosition::create(['id' => 1, 'name' => 'President', 'shortname' => 'ps']) + ->user()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'position_short' => 'ps']); + + HasOneThroughTestPosition::first()->contract()->findOrFail(1); + } + + public function testFirstRetrievesFirstRecord() + { + $this->seedData(); + $contract = HasOneThroughTestPosition::first()->contract()->first(); + + $this->assertNotNull($contract); + $this->assertSame('A title', $contract->title); + } + + public function testAllColumnsAreRetrievedByDefault() + { + $this->seedData(); + $contract = HasOneThroughTestPosition::first()->contract()->first(); + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', + ], array_keys($contract->getAttributes())); + } + + public function testOnlyProperColumnsAreSelectedIfProvided() + { + $this->seedData(); + $contract = HasOneThroughTestPosition::first()->contract()->first(['title', 'body']); + + $this->assertEquals([ + 'title', + 'body', + 'laravel_through_key', + ], array_keys($contract->getAttributes())); + } + + public function testChunkReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $position = HasOneThroughTestPosition::find(1); + + $position->contract()->chunk(10, function ($contractsChunk) { + $contract = $contractsChunk->first(); + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', ], array_keys($contract->getAttributes())); + }); + } + + public function testCursorReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $position = HasOneThroughTestPosition::find(1); + + $contracts = $position->contract()->cursor(); + + foreach ($contracts as $contract) { + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', ], array_keys($contract->getAttributes())); + } + } + + public function testEachReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $position = HasOneThroughTestPosition::find(1); + + $position->contract()->each(function ($contract) { + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', ], array_keys($contract->getAttributes())); + }); + } + + public function testLazyReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $position = HasOneThroughTestPosition::find(1); + + $position->contract()->lazy()->each(function ($contract) { + $this->assertEquals([ + 'id', + 'user_id', + 'title', + 'body', + 'email', + 'created_at', + 'updated_at', + 'laravel_through_key', ], array_keys($contract->getAttributes())); + }); + } + + public function testIntermediateSoftDeletesAreIgnored() + { + $this->seedData(); + HasOneThroughSoftDeletesTestUser::first()->delete(); + + $contract = HasOneThroughSoftDeletesTestPosition::first()->contract; + + $this->assertSame('A title', $contract->title); + } + + public function testEagerLoadingLoadsRelatedModelsCorrectly() + { + $this->seedData(); + $position = HasOneThroughSoftDeletesTestPosition::with('contract')->first(); + + $this->assertSame('ps', $position->shortname); + $this->assertSame('A title', $position->contract->title); + } + + /** + * Helpers... + */ + protected function seedData() + { + HasOneThroughTestPosition::create(['id' => 1, 'name' => 'President', 'shortname' => 'ps']) + ->user()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'position_short' => 'ps']) + ->contract()->create(['title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com']); + } + + protected function seedDataExtended() + { + $position = HasOneThroughTestPosition::create(['id' => 2, 'name' => 'Vice President', 'shortname' => 'vp']); + $position->user()->create(['id' => 2, 'email' => 'example1@gmail.com', 'position_short' => 'vp']) + ->contract()->create( + ['title' => 'Example1 title1', 'body' => 'Example1 body1', 'email' => 'example1contract1@gmail.com'] + ); + } + + /** + * Seed data for a default HasOneThrough setup. + */ + protected function seedDefaultData() + { + HasOneThroughDefaultTestPosition::create(['id' => 1, 'name' => 'President']) + ->user()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com']) + ->contract()->create(['title' => 'A title', 'body' => 'A body']); + } + + /** + * Drop the default tables. + */ + protected function resetDefault() + { + $this->schema()->drop('users_default'); + $this->schema()->drop('contracts_default'); + $this->schema()->drop('positions_default'); + } + + /** + * Migrate tables for classes with a Laravel "default" HasOneThrough setup. + */ + protected function migrateDefault() + { + $this->schema()->create('users_default', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->unsignedInteger('has_one_through_default_test_position_id')->unique()->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('contracts_default', function ($table) { + $table->increments('id'); + $table->integer('has_one_through_default_test_user_id')->unique(); + $table->string('title'); + $table->text('body'); + $table->timestamps(); + }); + + $this->schema()->create('positions_default', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class HasOneThroughTestUser extends Eloquent +{ + protected ?string $table = 'users'; + protected array $guarded = []; + + public function contract() + { + return $this->hasOne(HasOneThroughTestContract::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class HasOneThroughTestContract extends Eloquent +{ + protected ?string $table = 'contracts'; + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(HasOneThroughTestUser::class, 'user_id'); + } +} + +class HasOneThroughTestPosition extends Eloquent +{ + protected ?string $table = 'positions'; + protected array $guarded = []; + + public function contract() + { + return $this->hasOneThrough(HasOneThroughTestContract::class, HasOneThroughTestUser::class, 'position_id', 'user_id'); + } + + public function user() + { + return $this->hasOne(HasOneThroughTestUser::class, 'position_id'); + } +} + +/** + * Eloquent Models... + */ +class HasOneThroughDefaultTestUser extends Eloquent +{ + protected ?string $table = 'users_default'; + protected array $guarded = []; + + public function contract() + { + return $this->hasOne(HasOneThroughDefaultTestContract::class); + } +} + +/** + * Eloquent Models... + */ +class HasOneThroughDefaultTestContract extends Eloquent +{ + protected ?string $table = 'contracts_default'; + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(HasOneThroughDefaultTestUser::class); + } +} + +class HasOneThroughDefaultTestPosition extends Eloquent +{ + protected ?string $table = 'positions_default'; + protected array $guarded = []; + + public function contract() + { + return $this->hasOneThrough(HasOneThroughDefaultTestContract::class, HasOneThroughDefaultTestUser::class); + } + + public function user() + { + return $this->hasOne(HasOneThroughDefaultTestUser::class); + } +} + +class HasOneThroughIntermediateTestPosition extends Eloquent +{ + protected ?string $table = 'positions'; + protected array $guarded = []; + + public function contract() + { + return $this->hasOneThrough(HasOneThroughTestContract::class, HasOneThroughTestUser::class, 'position_short', 'email', 'shortname', 'email'); + } + + public function user() + { + return $this->hasOne(HasOneThroughTestUser::class, 'position_id'); + } +} + +class HasOneThroughSoftDeletesTestUser extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'users'; + protected array $guarded = []; + + public function contract() + { + return $this->hasOne(HasOneThroughSoftDeletesTestContract::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class HasOneThroughSoftDeletesTestContract extends Eloquent +{ + protected ?string $table = 'contracts'; + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(HasOneThroughSoftDeletesTestUser::class, 'user_id'); + } +} + +class HasOneThroughSoftDeletesTestPosition extends Eloquent +{ + protected ?string $table = 'positions'; + protected array $guarded = []; + + public function contract() + { + return $this->hasOneThrough(HasOneThroughSoftDeletesTestContract::class, HasOneThroughTestUser::class, 'position_id', 'user_id'); + } + + public function user() + { + return $this->hasOne(HasOneThroughSoftDeletesTestUser::class, 'position_id'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneThroughOfManyTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneThroughOfManyTest.php new file mode 100755 index 000000000..074b90721 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneThroughOfManyTest.php @@ -0,0 +1,764 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:']); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + public function createSchema(): void + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + }); + + $this->schema()->create('intermediates', function ($table) { + $table->increments('id'); + $table->foreignId('user_id'); + }); + + $this->schema()->create('logins', function ($table) { + $table->increments('id'); + $table->foreignId('intermediate_id'); + $table->dateTime('deleted_at')->nullable(); + }); + + $this->schema()->create('states', function ($table) { + $table->increments('id'); + $table->string('state'); + $table->string('type'); + $table->foreignId('intermediate_id'); + $table->timestamps(); + }); + + $this->schema()->create('prices', function ($table) { + $table->increments('id'); + $table->dateTime('published_at'); + $table->foreignId('intermediate_id'); + }); + } + + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('intermediates'); + $this->schema()->drop('logins'); + $this->schema()->drop('states'); + $this->schema()->drop('prices'); + + parent::tearDown(); + } + + public function testItGuessesRelationName(): void + { + $user = HasOneThroughOfManyTestUser::make(); + $this->assertSame('latest_login', $user->latest_login()->getRelationName()); + } + + public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationName(): void + { + $model = HasOneThroughOfManyTestModel::make(); + $this->assertSame('logins_of_many', $model->logins()->getRelationName()); + } + + public function testRelationNameCanBeSet(): void + { + $user = HasOneThroughOfManyTestUser::create(); + + $relation = $user->latest_login()->ofMany('id', 'max', 'foo'); + $this->assertSame('foo', $relation->getRelationName()); + + $relation = $user->latest_login()->latestOfMany('id', 'bar'); + $this->assertSame('bar', $relation->getRelationName()); + + $relation = $user->latest_login()->oldestOfMany('id', 'baz'); + $this->assertSame('baz', $relation->getRelationName()); + } + + public function testCorrectLatestOfManyQuery(): void + { + $user = HasOneThroughOfManyTestUser::create(); + $relation = $user->latest_login(); + $this->assertSame('select "logins".* from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" inner join (select MAX("logins"."id") as "id_aggregate", "intermediates"."user_id" from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" where "intermediates"."user_id" = ? group by "intermediates"."user_id") as "latest_login" on "latest_login"."id_aggregate" = "logins"."id" and "latest_login"."user_id" = "intermediates"."user_id" where "intermediates"."user_id" = ?', $relation->getQuery()->toSql()); + } + + public function testEagerLoadingAppliesConstraintsToInnerJoinSubQuery(): void + { + $user = HasOneThroughOfManyTestUser::create(); + $relation = $user->latest_login(); + $relation->addEagerConstraints([$user]); + $this->assertSame('select MAX("logins"."id") as "id_aggregate", "intermediates"."user_id" from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" where "intermediates"."user_id" = ? and "intermediates"."user_id" in (1) group by "intermediates"."user_id"', $relation->getOneOfManySubQuery()->toSql()); + } + + public function testEagerLoadingAppliesConstraintsToQuery(): void + { + $user = HasOneThroughOfManyTestUser::create(); + $relation = $user->latest_login(); + $relation->addEagerConstraints([$user]); + $this->assertSame('select "logins".* from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" inner join (select MAX("logins"."id") as "id_aggregate", "intermediates"."user_id" from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" where "intermediates"."user_id" = ? and "intermediates"."user_id" in (1) group by "intermediates"."user_id") as "latest_login" on "latest_login"."id_aggregate" = "logins"."id" and "latest_login"."user_id" = "intermediates"."user_id" where "intermediates"."user_id" = ?', $relation->getQuery()->toSql()); + } + + public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalScope(): void + { + HasOneThroughOfManyTestLogin::addGlobalScope('test', function ($query) { + $query->orderBy($query->qualifyColumn('id')); + }); + + $user = HasOneThroughOfManyTestUser::create(); + $relation = $user->latest_login_without_global_scope(); + $relation->addEagerConstraints([$user]); + $this->assertSame('select "logins".* from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" inner join (select MAX("logins"."id") as "id_aggregate", "intermediates"."user_id" from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" where "intermediates"."user_id" = ? and "intermediates"."user_id" in (1) group by "intermediates"."user_id") as "latestOfMany" on "latestOfMany"."id_aggregate" = "logins"."id" and "latestOfMany"."user_id" = "intermediates"."user_id" where "intermediates"."user_id" = ?', $relation->getQuery()->toSql()); + + HasOneThroughOfManyTestLogin::addGlobalScope('test', function ($query) { + }); + } + + public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalScopeWithComplexQuery(): void + { + HasOneThroughOfManyTestPrice::addGlobalScope('test', function ($query) { + $query->orderBy($query->qualifyColumn('id')); + }); + + $user = HasOneThroughOfManyTestUser::create(); + $relation = $user->price_without_global_scope(); + $this->assertSame('select "prices".* from "prices" inner join "intermediates" on "intermediates"."id" = "prices"."intermediate_id" inner join (select max("prices"."id") as "id_aggregate", min("prices"."published_at") as "published_at_aggregate", "intermediates"."user_id" from "prices" inner join "intermediates" on "intermediates"."id" = "prices"."intermediate_id" inner join (select max("prices"."published_at") as "published_at_aggregate", "intermediates"."user_id" from "prices" inner join "intermediates" on "intermediates"."id" = "prices"."intermediate_id" where "published_at" < ? and "intermediates"."user_id" = ? group by "intermediates"."user_id") as "price_without_global_scope" on "price_without_global_scope"."published_at_aggregate" = "prices"."published_at" and "price_without_global_scope"."user_id" = "intermediates"."user_id" where "published_at" < ? group by "intermediates"."user_id") as "price_without_global_scope" on "price_without_global_scope"."id_aggregate" = "prices"."id" and "price_without_global_scope"."published_at_aggregate" = "prices"."published_at" and "price_without_global_scope"."user_id" = "intermediates"."user_id" where "intermediates"."user_id" = ?', $relation->getQuery()->toSql()); + + HasOneThroughOfManyTestPrice::addGlobalScope('test', function ($query) { + }); + } + + public function testQualifyingSubSelectColumn(): void + { + $user = HasOneThroughOfManyTestUser::make(); + $this->assertSame('latest_login.id', $user->latest_login()->qualifySubSelectColumn('id')); + } + + public function testItFailsWhenUsingInvalidAggregate(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid aggregate [count] used within ofMany relation. Available aggregates: MIN, MAX'); + $user = HasOneThroughOfManyTestUser::make(); + $user->latest_login_with_invalid_aggregate(); + } + + public function testItGetsCorrectResults(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $previousLogin = $user->intermediates->last()->logins()->create(); + $latestLogin = $user->intermediates->first()->logins()->create(); + + $result = $user->latest_login()->getResults(); + $this->assertNotNull($result); + $this->assertSame($latestLogin->id, $result->id); + } + + public function testResultDoesNotHaveAggregateColumn(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(1)->create(); + $user->intermediates->first()->logins()->create(); + + $result = $user->latest_login()->getResults(); + $this->assertNotNull($result); + $this->assertFalse(isset($result->id_aggregate)); + } + + public function testItGetsCorrectResultsUsingShortcutMethod(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $previousLogin = $user->intermediates->last()->logins()->create(); + $latestLogin = $user->intermediates->first()->logins()->create(); + + $result = $user->latest_login_with_shortcut()->getResults(); + $this->assertNotNull($result); + $this->assertSame($latestLogin->id, $result->id); + } + + public function testItGetsCorrectResultsUsingShortcutReceivingMultipleColumnsMethod(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $user->intermediates->last()->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->intermediates->first()->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $result = $user->price_with_shortcut()->getResults(); + $this->assertNotNull($result); + $this->assertSame($price->id, $result->id); + } + + public function testKeyIsAddedToAggregatesWhenMissing(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $user->intermediates->last()->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->intermediates->first()->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $result = $user->price_without_key_in_aggregates()->getResults(); + $this->assertNotNull($result); + $this->assertSame($price->id, $result->id); + } + + public function testItGetsWithConstraintsCorrectResults(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $previousLogin = $user->intermediates->last()->logins()->create(); + $user->intermediates->first()->logins()->create(); + + $result = $user->latest_login()->whereKey($previousLogin->getKey())->getResults(); + $this->assertNull($result); + } + + public function testItEagerLoadsCorrectModels(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $user->intermediates->last()->logins()->create(); + $latestLogin = $user->intermediates->first()->logins()->create(); + + $user = HasOneThroughOfManyTestUser::with('latest_login')->first(); + + $this->assertTrue($user->relationLoaded('latest_login')); + $this->assertSame($latestLogin->id, $user->latest_login->id); + } + + public function testItJoinsOtherTableInSubQuery(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $user->intermediates->first()->logins()->create(); + + $this->assertNull($user->latest_login_with_foo_state); + + $user->unsetRelation('latest_login_with_foo_state'); + $user->intermediates->first()->states()->create([ + 'type' => 'foo', + 'state' => 'draft', + ]); + + $this->assertNotNull($user->latest_login_with_foo_state); + } + + public function testHasNested(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $previousLogin = $user->intermediates->first()->logins()->create(); + $latestLogin = $user->intermediates->last()->logins()->create(); + + $found = HasOneThroughOfManyTestUser::whereHas('latest_login', function ($query) use ($latestLogin) { + $query->where('logins.id', $latestLogin->id); + })->exists(); + $this->assertTrue($found); + + $found = HasOneThroughOfManyTestUser::whereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + $this->assertFalse($found); + } + + public function testWithHasNested(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $previousLogin = $user->intermediates->first()->logins()->create(); + $latestLogin = $user->intermediates->last()->logins()->create(); + + $found = HasOneThroughOfManyTestUser::withWhereHas('latest_login', function ($query) use ($latestLogin) { + $query->where('logins.id', $latestLogin->id); + })->first(); + + $this->assertTrue((bool) $found); + $this->assertTrue($found->relationLoaded('latest_login')); + $this->assertEquals($found->latest_login->id, $latestLogin->id); + + $found = HasOneThroughOfManyTestUser::withWhereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + + $this->assertFalse($found); + } + + public function testHasCount(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $user->intermediates->last()->logins()->create(); + $user->intermediates->first()->logins()->create(); + + $user = HasOneThroughOfManyTestUser::withCount('latest_login')->first(); + $this->assertEquals(1, $user->latest_login_count); + } + + public function testExists(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $previousLogin = $user->intermediates->last()->logins()->create(); + $latestLogin = $user->intermediates->first()->logins()->create(); + + $this->assertFalse($user->latest_login()->whereKey($previousLogin->getKey())->exists()); + $this->assertTrue($user->latest_login()->whereKey($latestLogin->getKey())->exists()); + } + + public function testIsMethod(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $login1 = $user->intermediates->last()->logins()->create(); + $login2 = $user->intermediates->first()->logins()->create(); + + $this->assertFalse($user->latest_login()->is($login1)); + $this->assertTrue($user->latest_login()->is($login2)); + } + + public function testIsNotMethod(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $login1 = $user->intermediates->last()->logins()->create(); + $login2 = $user->intermediates->first()->logins()->create(); + + $this->assertTrue($user->latest_login()->isNot($login1)); + $this->assertFalse($user->latest_login()->isNot($login2)); + } + + public function testGet(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $previousLogin = $user->intermediates->last()->logins()->create(); + $latestLogin = $user->intermediates->first()->logins()->create(); + + $latestLogins = $user->latest_login()->get(); + $this->assertCount(1, $latestLogins); + $this->assertSame($latestLogin->id, $latestLogins->first()->id); + + $latestLogins = $user->latest_login()->whereKey($previousLogin->getKey())->get(); + $this->assertCount(0, $latestLogins); + } + + public function testCount(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $user->intermediates->last()->logins()->create(); + $user->intermediates->first()->logins()->create(); + + $this->assertSame(1, $user->latest_login()->count()); + } + + public function testAggregate(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $firstLogin = $user->intermediates->first()->logins()->create(); + $user->intermediates->last()->logins()->create(); + + $user = HasOneThroughOfManyTestUser::first(); + $this->assertSame($firstLogin->id, $user->first_login->id); + } + + public function testJoinConstraints(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $user->intermediates->last()->states()->create([ + 'type' => 'foo', + 'state' => 'draft', + ]); + $currentForState = $user->intermediates->first()->states()->create([ + 'type' => 'foo', + 'state' => 'active', + ]); + $user->intermediates->first()->states()->create([ + 'type' => 'bar', + 'state' => 'baz', + ]); + + $user = HasOneThroughOfManyTestUser::first(); + $this->assertSame($currentForState->id, $user->foo_state->id); + } + + public function testMultipleAggregates(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $user->intermediates->last()->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->intermediates->first()->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $user = HasOneThroughOfManyTestUser::first(); + $this->assertSame($price->id, $user->price->id); + } + + public function testEagerLoadingWithMultipleAggregates(): void + { + $user1 = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + $user2 = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + + $user1->intermediates->last()->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $user1Price = $user1->intermediates->first()->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $user1->intermediates->first()->prices()->create([ + 'published_at' => '2021-04-01 00:00:00', + ]); + + $user2Price = $user2->intermediates->last()->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $user2->intermediates->first()->prices()->create([ + 'published_at' => '2021-04-01 00:00:00', + ]); + + $users = HasOneThroughOfManyTestUser::with('price')->get(); + + $this->assertNotNull($users[0]->price); + $this->assertSame($user1Price->id, $users[0]->price->id); + + $this->assertNotNull($users[1]->price); + $this->assertSame($user2Price->id, $users[1]->price->id); + } + + public function testWithExists(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(1)->create(); + + $user = HasOneThroughOfManyTestUser::withExists('latest_login')->first(); + $this->assertFalse($user->latest_login_exists); + + $user->intermediates->first()->logins()->create(); + $user = HasOneThroughOfManyTestUser::withExists('latest_login')->first(); + $this->assertTrue($user->latest_login_exists); + } + + public function testWithExistsWithConstraintsInJoinSubSelect(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(1)->create(); + $user = HasOneThroughOfManyTestUser::withExists('foo_state')->first(); + + $this->assertFalse($user->foo_state_exists); + + $user->intermediates->first()->states()->create([ + 'type' => 'foo', + 'state' => 'bar', + ]); + $user = HasOneThroughOfManyTestUser::withExists('foo_state')->first(); + $this->assertTrue($user->foo_state_exists); + } + + public function testWithSoftDeletes(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(1)->create(); + $user->intermediates->first()->logins()->create(); + $user->latest_login_with_soft_deletes; + $this->assertNotNull($user->latest_login_with_soft_deletes); + } + + public function testWithConstraintNotInAggregate(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + + $previousFoo = $user->intermediates->last()->states()->create([ + 'type' => 'foo', + 'state' => 'bar', + 'updated_at' => '2020-01-01 00:00:00', + ]); + $newFoo = $user->intermediates->first()->states()->create([ + 'type' => 'foo', + 'state' => 'active', + 'updated_at' => '2021-01-01 12:00:00', + ]); + $newBar = $user->intermediates->first()->states()->create([ + 'type' => 'bar', + 'state' => 'active', + 'updated_at' => '2021-01-01 12:00:00', + ]); + + $this->assertSame($newFoo->id, $user->last_updated_foo_state->id); + } + + public function testItGetsCorrectResultUsingAtLeastTwoAggregatesDistinctFromId(): void + { + $user = HasOneThroughOfManyTestUser::factory()->hasIntermediates(2)->create(); + + $expectedState = $user->intermediates->last()->states()->create([ + 'state' => 'state', + 'type' => 'type', + 'created_at' => '2023-01-01', + 'updated_at' => '2023-01-03', + ]); + + $user->intermediates->first()->states()->create([ + 'state' => 'state', + 'type' => 'type', + 'created_at' => '2023-01-01', + 'updated_at' => '2023-01-02', + ]); + + $this->assertSame($user->latest_updated_latest_created_state->id, $expectedState->id); + } + + protected function connection(): Connection + { + return Eloquent::getConnectionResolver()->connection(); + } + + protected function schema(): Builder + { + return $this->connection()->getSchemaBuilder(); + } +} + +class HasOneThroughOfManyTestUser extends Eloquent +{ + use HasFactory; + + protected ?string $table = 'users'; + protected array $guarded = []; + public bool $timestamps = false; + protected static string $factory = HasOneThroughOfManyTestUserFactory::class; + + public function intermediates(): HasMany + { + return $this->hasMany(HasOneThroughOfManyTestIntermediate::class, 'user_id'); + } + + public function logins(): HasManyThrough + { + return $this->through('intermediates')->has('logins'); + } + + public function latest_login(): HasOneThrough + { + return $this->hasOneThrough( + HasOneThroughOfManyTestLogin::class, + HasOneThroughOfManyTestIntermediate::class, + 'user_id', + 'intermediate_id' + )->ofMany(); + } + + public function latest_login_with_soft_deletes(): HasOneThrough + { + return $this->hasOneThrough( + HasOneThroughOfManyTestLoginWithSoftDeletes::class, + HasOneThroughOfManyTestIntermediate::class, + 'user_id', + 'intermediate_id', + )->ofMany(); + } + + public function latest_login_with_shortcut(): HasOneThrough + { + return $this->logins()->one()->latestOfMany(); + } + + public function latest_login_with_invalid_aggregate(): HasOneThrough + { + return $this->logins()->one()->ofMany('id', 'count'); + } + + public function latest_login_without_global_scope(): HasOneThrough + { + return $this->logins()->one()->withoutGlobalScopes()->latestOfMany(); + } + + public function first_login(): HasOneThrough + { + return $this->logins()->one()->ofMany('id', 'min'); + } + + public function latest_login_with_foo_state(): HasOneThrough + { + return $this->logins()->one()->ofMany( + ['id' => 'max'], + function ($query) { + $query->join('states', 'states.intermediate_id', 'logins.intermediate_id') + ->where('states.type', 'foo'); + } + ); + } + + public function states(): HasManyThrough + { + return $this->through($this->intermediates()) + ->has(fn ($intermediate) => $intermediate->states()); + } + + public function foo_state(): HasOneThrough + { + return $this->states()->one()->ofMany( + ['id' => 'max'], + function ($q) { + $q->where('type', 'foo'); + } + ); + } + + public function last_updated_foo_state(): HasOneThrough + { + return $this->states()->one()->ofMany([ + 'updated_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('type', 'foo'); + }); + } + + public function prices(): HasManyThrough + { + return $this->throughIntermediates()->hasPrices(); + } + + public function price(): HasOneThrough + { + return $this->prices()->one()->ofMany([ + 'published_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('published_at', '<', now()); + }); + } + + public function price_without_key_in_aggregates(): HasOneThrough + { + return $this->prices()->one()->ofMany(['published_at' => 'MAX']); + } + + public function price_with_shortcut(): HasOneThrough + { + return $this->prices()->one()->latestOfMany(['published_at', 'id']); + } + + public function price_without_global_scope(): HasOneThrough + { + return $this->prices()->one()->withoutGlobalScopes()->ofMany([ + 'published_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('published_at', '<', now()); + }); + } + + public function latest_updated_latest_created_state(): HasOneThrough + { + return $this->states()->one()->ofMany([ + 'updated_at' => 'max', + 'created_at' => 'max', + ]); + } +} + +class HasOneThroughOfManyTestIntermediate extends Eloquent +{ + use HasFactory; + + protected ?string $table = 'intermediates'; + protected array $guarded = []; + public bool $timestamps = false; + protected static string $factory = HasOneThroughOfManyTestIntermediateFactory::class; + + public function logins(): HasMany + { + return $this->hasMany(HasOneThroughOfManyTestLogin::class, 'intermediate_id'); + } + + public function states(): HasMany + { + return $this->hasMany(HasOneThroughOfManyTestState::class, 'intermediate_id'); + } + + public function prices(): HasMany + { + return $this->hasMany(HasOneThroughOfManyTestPrice::class, 'intermediate_id'); + } +} + +class HasOneThroughOfManyTestModel extends Eloquent +{ + public function logins(): HasOneThrough + { + return $this->hasOneThrough( + HasOneThroughOfManyTestLogin::class, + HasOneThroughOfManyTestIntermediate::class, + 'user_id', + 'intermediate_id', + )->ofMany(); + } +} + +class HasOneThroughOfManyTestLogin extends Eloquent +{ + protected ?string $table = 'logins'; + protected array $guarded = []; + public bool $timestamps = false; +} + +class HasOneThroughOfManyTestLoginWithSoftDeletes extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'logins'; + protected array $guarded = []; + public bool $timestamps = false; +} + +class HasOneThroughOfManyTestState extends Eloquent +{ + protected ?string $table = 'states'; + protected array $guarded = []; + public bool $timestamps = true; + protected array $fillable = ['type', 'state', 'updated_at']; +} + +class HasOneThroughOfManyTestPrice extends Eloquent +{ + protected ?string $table = 'prices'; + protected array $guarded = []; + public bool $timestamps = false; + protected array $fillable = ['published_at']; + protected array $casts = ['published_at' => 'datetime']; +} + +class HasOneThroughOfManyTestUserFactory extends Factory +{ + protected ?string $model = HasOneThroughOfManyTestUser::class; + + public function definition(): array + { + return []; + } +} + +class HasOneThroughOfManyTestIntermediateFactory extends Factory +{ + protected ?string $model = HasOneThroughOfManyTestIntermediate::class; + + public function definition(): array + { + return ['user_id' => HasOneThroughOfManyTestUser::factory()]; + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentIntegrationTest.php b/tests/Database/Laravel/DatabaseEloquentIntegrationTest.php new file mode 100644 index 000000000..d80664725 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentIntegrationTest.php @@ -0,0 +1,3103 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'second_connection'); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema('default')->create('test_orders', function ($table) { + $table->increments('id'); + $table->string('item_type'); + $table->integer('item_id'); + $table->timestamps(); + }); + + $this->schema('default')->create('with_json', function ($table) { + $table->increments('id'); + $table->text('json')->default(json_encode([])); + }); + + $this->schema('second_connection')->create('test_items', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema('default')->create('users_with_space_in_column_name', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email address'); + $table->timestamps(); + }); + + $this->schema()->create('users_having_uuids', function (Blueprint $table) { + $table->id(); + $table->uuid(); + $table->string('name'); + $table->tinyInteger('role'); + $table->string('role_string'); + }); + + foreach (['default', 'second_connection'] as $connection) { + $this->schema($connection)->create('users', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email'); + $table->timestamp('birthday', 6)->nullable(); + $table->timestamps(); + }); + + $this->schema($connection)->create('unique_users', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + // Unique constraint will be applied only for non-null values + $table->string('screen_name')->nullable()->unique(); + $table->string('email')->unique(); + $table->timestamp('birthday', 6)->nullable(); + $table->timestamps(); + }); + + $this->schema($connection)->create('friends', function ($table) { + $table->integer('user_id'); + $table->integer('friend_id'); + $table->integer('friend_level_id')->nullable(); + }); + + $this->schema($connection)->create('posts', function ($table) { + $table->increments('id'); + $table->integer('user_id'); + $table->integer('parent_id')->nullable(); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema($connection)->create('comments', function ($table) { + $table->increments('id'); + $table->integer('post_id'); + $table->string('content'); + $table->timestamps(); + }); + + $this->schema($connection)->create('friend_levels', function ($table) { + $table->increments('id'); + $table->string('level'); + $table->timestamps(); + }); + + $this->schema($connection)->create('photos', function ($table) { + $table->increments('id'); + $table->morphs('imageable'); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema($connection)->create('soft_deleted_users', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email'); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->schema($connection)->create('tags', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema($connection)->create('taggables', function ($table) { + $table->integer('tag_id'); + $table->morphs('taggable'); + $table->string('taxonomy')->nullable(); + }); + + $this->schema($connection)->create('categories', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->integer('parent_id')->nullable(); + $table->timestamps(); + }); + + $this->schema($connection)->create('achievements', function ($table) { + $table->increments('id'); + $table->integer('status')->nullable(); + }); + + $this->schema($connection)->create('eloquent_test_achievement_eloquent_test_user', function ($table) { + $table->integer('eloquent_test_achievement_id'); + $table->integer('eloquent_test_user_id'); + }); + } + + $this->schema($connection)->create('non_incrementing_users', function ($table) { + $table->string('name')->nullable(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + foreach (['default', 'second_connection'] as $connection) { + $this->schema($connection)->drop('users'); + $this->schema($connection)->drop('friends'); + $this->schema($connection)->drop('posts'); + $this->schema($connection)->drop('friend_levels'); + $this->schema($connection)->drop('photos'); + } + + Relation::morphMap([], false); + Eloquent::unsetConnectionResolver(); + + Carbon::setTestNow(null); + Str::createUuidsNormally(); + DB::flushQueryLog(); + + parent::tearDown(); + } + + /** + * Tests... + */ + public function testBasicModelRetrieval() + { + EloquentTestUser::insert([['id' => 1, 'email' => 'taylorotwell@gmail.com'], ['id' => 2, 'email' => 'abigailotwell@gmail.com']]); + + $this->assertEquals(2, EloquentTestUser::count()); + + $this->assertFalse(EloquentTestUser::where('email', 'taylorotwell@gmail.com')->doesntExist()); + $this->assertTrue(EloquentTestUser::where('email', 'mohamed@laravel.com')->doesntExist()); + + $model = EloquentTestUser::where('email', 'taylorotwell@gmail.com')->first(); + $this->assertSame('taylorotwell@gmail.com', $model->email); + $this->assertTrue(isset($model->email)); + $this->assertTrue(isset($model->friends)); + + $model = EloquentTestUser::find(1); + $this->assertInstanceOf(EloquentTestUser::class, $model); + $this->assertEquals(1, $model->id); + + $model = EloquentTestUser::find(2); + $this->assertInstanceOf(EloquentTestUser::class, $model); + $this->assertEquals(2, $model->id); + + $missing = EloquentTestUser::find(3); + $this->assertNull($missing); + + $collection = EloquentTestUser::find([]); + $this->assertInstanceOf(Collection::class, $collection); + $this->assertCount(0, $collection); + + $collection = EloquentTestUser::find([1, 2, 3]); + $this->assertInstanceOf(Collection::class, $collection); + $this->assertCount(2, $collection); + + $models = EloquentTestUser::where('id', 1)->cursor(); + foreach ($models as $model) { + $this->assertEquals(1, $model->id); + $this->assertSame('default', $model->getConnectionName()); + } + + $records = DB::table('users')->where('id', 1)->cursor(); + foreach ($records as $record) { + $this->assertEquals(1, $record->id); + } + + $records = DB::cursor('select * from users where id = ?', [1]); + foreach ($records as $record) { + $this->assertEquals(1, $record->id); + } + } + + public function testBasicModelCollectionRetrieval() + { + EloquentTestUser::insert([['id' => 1, 'email' => 'taylorotwell@gmail.com'], ['id' => 2, 'email' => 'abigailotwell@gmail.com']]); + + $models = EloquentTestUser::oldest('id')->get(); + + $this->assertCount(2, $models); + $this->assertInstanceOf(Collection::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + } + + public function testPaginatedModelCollectionRetrieval() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ['id' => 3, 'email' => 'foo@gmail.com'], + ]); + + Paginator::currentPageResolver(function () { + return 1; + }); + $models = EloquentTestUser::oldest('id')->paginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + + Paginator::currentPageResolver(function () { + return 2; + }); + $models = EloquentTestUser::oldest('id')->paginate(2); + + $this->assertCount(1, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertSame('foo@gmail.com', $models[0]->email); + } + + public function testPaginatedModelCollectionRetrievalUsingCallablePerPage() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ['id' => 3, 'email' => 'foo@gmail.com'], + ]); + + Paginator::currentPageResolver(function () { + return 1; + }); + $models = EloquentTestUser::oldest('id')->paginate(function ($total) { + return $total <= 3 ? 3 : 2; + }); + + $this->assertCount(3, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertInstanceOf(EloquentTestUser::class, $models[2]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + $this->assertSame('foo@gmail.com', $models[2]->email); + + Paginator::currentPageResolver(function () { + return 2; + }); + $models = EloquentTestUser::oldest('id')->paginate(function ($total) { + return $total <= 3 ? 3 : 2; + }); + + $this->assertCount(0, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + + EloquentTestUser::create(['id' => 4, 'email' => 'bar@gmail.com']); + + Paginator::currentPageResolver(function () { + return 1; + }); + $models = EloquentTestUser::oldest('id')->paginate(function ($total) { + return $total <= 3 ? 3 : 2; + }); + + $this->assertCount(2, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + + Paginator::currentPageResolver(function () { + return 2; + }); + $models = EloquentTestUser::oldest('id')->paginate(function ($total) { + return $total <= 3 ? 3 : 2; + }); + + $this->assertCount(2, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('foo@gmail.com', $models[0]->email); + $this->assertSame('bar@gmail.com', $models[1]->email); + } + + public function testPaginatedModelCollectionRetrievalWhenNoElements() + { + Paginator::currentPageResolver(function () { + return 1; + }); + $models = EloquentTestUser::oldest('id')->paginate(2); + + $this->assertCount(0, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + + Paginator::currentPageResolver(function () { + return 2; + }); + $models = EloquentTestUser::oldest('id')->paginate(2); + + $this->assertCount(0, $models); + } + + public function testPaginatedModelCollectionRetrievalWhenNoElementsAndDefaultPerPage() + { + $models = EloquentTestUser::oldest('id')->paginate(); + + $this->assertCount(0, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + } + + public function testCountForPaginationWithGrouping() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ['id' => 3, 'email' => 'foo@gmail.com'], + ['id' => 4, 'email' => 'foo@gmail.com'], + ]); + + $query = EloquentTestUser::groupBy('email')->getQuery(); + + $this->assertEquals(3, $query->getCountForPagination()); + } + + public function testCountForPaginationWithGroupingAndSubSelects() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ['id' => 3, 'email' => 'foo@gmail.com'], + ['id' => 4, 'email' => 'foo@gmail.com'], + ]); + $user1 = EloquentTestUser::find(1); + + $user1->friends()->create(['id' => 5, 'email' => 'friend@gmail.com']); + + $query = EloquentTestUser::select([ + 'id', + 'friends_count' => EloquentTestUser::whereColumn('friend_id', 'user_id')->count(), + ])->groupBy('email')->getQuery(); + + $this->assertEquals(4, $query->getCountForPagination()); + } + + public function testCursorPaginatedModelCollectionRetrieval() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + $secondParams = ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ['id' => 3, 'email' => 'foo@gmail.com'], + ]); + + CursorPaginator::currentCursorResolver(function () { + return null; + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + $this->assertTrue($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + + CursorPaginator::currentCursorResolver(function () use ($secondParams) { + return new Cursor($secondParams); + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(1, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertSame('foo@gmail.com', $models[0]->email); + $this->assertFalse($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + } + + public function testPreviousCursorPaginatedModelCollectionRetrieval() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + $thirdParams = ['id' => 3, 'email' => 'foo@gmail.com'], + ]); + + CursorPaginator::currentCursorResolver(function () use ($thirdParams) { + return new Cursor($thirdParams, false); + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + $this->assertTrue($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + } + + public function testCursorPaginatedModelCollectionRetrievalWhenNoElements() + { + CursorPaginator::currentCursorResolver(function () { + return null; + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(0, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + + Paginator::currentPageResolver(function () { + return new Cursor(['id' => 1]); + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(0, $models); + } + + public function testCursorPaginatedModelCollectionRetrievalWhenNoElementsAndDefaultPerPage() + { + $models = EloquentTestUser::oldest('id')->cursorPaginate(); + + $this->assertCount(0, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + } + + public function testFirstOrNew() + { + $user1 = EloquentTestUser::firstOrNew( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro'] + ); + + $this->assertSame('Nuno Maduro', $user1->name); + } + + public function testFirstOrCreate() + { + $user1 = EloquentTestUser::firstOrCreate(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = EloquentTestUser::firstOrCreate( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = EloquentTestUser::firstOrCreate( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = EloquentTestUser::firstOrCreate( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } + + public function testCreateOrFirst() + { + $user1 = EloquentTestUniqueUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = EloquentTestUniqueUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = EloquentTestUniqueUser::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = EloquentTestUniqueUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } + + public function testCreateOrFirstNonAttributeFieldViolation() + { + // 'email' and 'screen_name' are unique and independent of each other. + EloquentTestUniqueUser::create([ + 'email' => 'taylorotwell+foo@gmail.com', + 'screen_name' => '@taylorotwell', + ]); + + $this->expectException(UniqueConstraintViolationException::class); + + // Although 'email' is expected to be unique and is passed as $attributes, + // if the 'screen_name' attribute listed in non-unique $values causes a violation, + // a UniqueConstraintViolationException should be thrown. + EloquentTestUniqueUser::createOrFirst( + ['email' => 'taylorotwell+bar@gmail.com'], + [ + 'screen_name' => '@taylorotwell', + ] + ); + } + + public function testCreateOrFirstWithinTransaction() + { + $user1 = EloquentTestUniqueUser::create(['email' => 'taylorotwell@gmail.com']); + + DB::transaction(function () use ($user1) { + $user2 = EloquentTestUniqueUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + }); + } + + public function testUpdateOrCreate() + { + $user1 = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + + $user2 = EloquentTestUser::updateOrCreate( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertSame('Taylor Otwell', $user2->name); + + $user3 = EloquentTestUser::updateOrCreate( + ['email' => 'themsaid@gmail.com'], + ['name' => 'Mohamed Said'] + ); + + $this->assertSame('Mohamed Said', $user3->name); + $this->assertEquals(2, EloquentTestUser::count()); + } + + public function testUpdateOrCreateOnDifferentConnection() + { + EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + + EloquentTestUser::on('second_connection')->updateOrCreate( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + EloquentTestUser::on('second_connection')->updateOrCreate( + ['email' => 'themsaid@gmail.com'], + ['name' => 'Mohamed Said'] + ); + + $this->assertEquals(1, EloquentTestUser::count()); + $this->assertEquals(2, EloquentTestUser::on('second_connection')->count()); + } + + public function testCheckAndCreateMethodsOnMultiConnections() + { + EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + EloquentTestUser::on('second_connection')->find( + EloquentTestUser::on('second_connection')->insert(['id' => 2, 'email' => 'themsaid@gmail.com']) + ); + + $user1 = EloquentTestUser::on('second_connection')->findOrNew(1); + $user2 = EloquentTestUser::on('second_connection')->findOrNew(2); + $this->assertFalse($user1->exists); + $this->assertTrue($user2->exists); + $this->assertSame('second_connection', $user1->getConnectionName()); + $this->assertSame('second_connection', $user2->getConnectionName()); + + $user1 = EloquentTestUser::on('second_connection')->firstOrNew(['email' => 'taylorotwell@gmail.com']); + $user2 = EloquentTestUser::on('second_connection')->firstOrNew(['email' => 'themsaid@gmail.com']); + $this->assertFalse($user1->exists); + $this->assertTrue($user2->exists); + $this->assertSame('second_connection', $user1->getConnectionName()); + $this->assertSame('second_connection', $user2->getConnectionName()); + + $this->assertEquals(1, EloquentTestUser::on('second_connection')->count()); + $user1 = EloquentTestUser::on('second_connection')->firstOrCreate(['email' => 'taylorotwell@gmail.com']); + $user2 = EloquentTestUser::on('second_connection')->firstOrCreate(['email' => 'themsaid@gmail.com']); + $this->assertSame('second_connection', $user1->getConnectionName()); + $this->assertSame('second_connection', $user2->getConnectionName()); + $this->assertEquals(2, EloquentTestUser::on('second_connection')->count()); + } + + public function testCreatingModelWithEmptyAttributes() + { + $model = EloquentTestNonIncrementing::create([]); + + $this->assertFalse($model->exists); + $this->assertFalse($model->wasRecentlyCreated); + } + + public function testChunk() + { + EloquentTestUser::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + EloquentTestUser::query()->orderBy('id', 'asc')->chunk(2, function (Collection $users, $page) use (&$chunks) { + if ($page == 1) { + $this->assertCount(2, $users); + $this->assertSame('First', $users[0]->name); + $this->assertSame('Second', $users[1]->name); + } else { + $this->assertCount(1, $users); + $this->assertSame('Third', $users[0]->name); + } + + $chunks++; + }); + + $this->assertEquals(2, $chunks); + } + + public function testChunksWithLimitsWhereLimitIsLessThanTotal() + { + EloquentTestUser::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + EloquentTestUser::query()->orderBy('id', 'asc')->limit(2)->chunk(2, function (Collection $users, $page) use (&$chunks) { + if ($page == 1) { + $this->assertCount(2, $users); + $this->assertSame('First', $users[0]->name); + $this->assertSame('Second', $users[1]->name); + } else { + $this->fail('Should only have had one page.'); + } + + $chunks++; + }); + + $this->assertEquals(1, $chunks); + } + + public function testChunksWithLimitsWhereLimitIsMoreThanTotal() + { + EloquentTestUser::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + EloquentTestUser::query()->orderBy('id', 'asc')->limit(10)->chunk(2, function (Collection $users, $page) use (&$chunks) { + if ($page == 1) { + $this->assertCount(2, $users); + $this->assertSame('First', $users[0]->name); + $this->assertSame('Second', $users[1]->name); + } elseif ($page === 2) { + $this->assertCount(1, $users); + $this->assertSame('Third', $users[0]->name); + } else { + $this->fail('Should have had two pages.'); + } + + $chunks++; + }); + + $this->assertEquals(2, $chunks); + } + + public function testChunksWithOffset() + { + EloquentTestUser::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + EloquentTestUser::query()->orderBy('id', 'asc')->offset(1)->chunk(2, function (Collection $users, $page) use (&$chunks) { + if ($page == 1) { + $this->assertCount(2, $users); + $this->assertSame('Second', $users[0]->name); + $this->assertSame('Third', $users[1]->name); + } else { + $this->fail('Should only have had one page.'); + } + + $chunks++; + }); + + $this->assertEquals(1, $chunks); + } + + public function testChunksWithOffsetWhereMoreThanTotal() + { + EloquentTestUser::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + EloquentTestUser::query()->orderBy('id', 'asc')->offset(3)->chunk(2, function () use (&$chunks) { + $chunks++; + }); + + $this->assertEquals(0, $chunks); + } + + public function testChunksWithLimitsAndOffsets() + { + EloquentTestUser::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ['name' => 'Fourth', 'email' => 'fourth@example.com'], + ['name' => 'Fifth', 'email' => 'fifth@example.com'], + ['name' => 'Sixth', 'email' => 'sixth@example.com'], + ['name' => 'Seventh', 'email' => 'seventh@example.com'], + ]); + + $chunks = 0; + + EloquentTestUser::query()->orderBy('id', 'asc')->offset(2)->limit(3)->chunk(2, function (Collection $users, $page) use (&$chunks) { + if ($page == 1) { + $this->assertCount(2, $users); + $this->assertSame('Third', $users[0]->name); + $this->assertSame('Fourth', $users[1]->name); + } elseif ($page == 2) { + $this->assertCount(1, $users); + $this->assertSame('Fifth', $users[0]->name); + } else { + $this->fail('Should only have had two pages.'); + } + + $chunks++; + }); + + $this->assertEquals(2, $chunks); + } + + public function testChunkByIdWithLimits() + { + EloquentTestUser::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + EloquentTestUser::query()->limit(2)->chunkById(2, function (Collection $users, $page) use (&$chunks) { + if ($page == 1) { + $this->assertCount(2, $users); + $this->assertSame('First', $users[0]->name); + $this->assertSame('Second', $users[1]->name); + } else { + $this->fail('Should only have had one page.'); + } + + $chunks++; + }); + + $this->assertEquals(1, $chunks); + } + + public function testChunkByIdWithOffsets() + { + EloquentTestUser::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + EloquentTestUser::query()->offset(1)->chunkById(2, function (Collection $users, $page) use (&$chunks) { + if ($page == 1) { + $this->assertCount(2, $users); + $this->assertSame('Second', $users[0]->name); + $this->assertSame('Third', $users[1]->name); + } else { + $this->fail('Should only have had one page.'); + } + + $chunks++; + }); + + $this->assertEquals(1, $chunks); + } + + public function testChunkByIdWithLimitsAndOffsets() + { + EloquentTestUser::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ['name' => 'Fourth', 'email' => 'fourth@example.com'], + ['name' => 'Fifth', 'email' => 'fifth@example.com'], + ['name' => 'Sixth', 'email' => 'sixth@example.com'], + ['name' => 'Seventh', 'email' => 'seventh@example.com'], + ]); + + $chunks = 0; + + EloquentTestUser::query()->offset(2)->limit(3)->chunkById(2, function (Collection $users, $page) use (&$chunks) { + if ($page == 1) { + $this->assertCount(2, $users); + $this->assertSame('Third', $users[0]->name); + $this->assertSame('Fourth', $users[1]->name); + } elseif ($page == 2) { + $this->assertCount(1, $users); + $this->assertSame('Fifth', $users[0]->name); + } else { + $this->fail('Should only have had two pages.'); + } + + $chunks++; + }); + + $this->assertEquals(2, $chunks); + } + + public function testChunkByIdWithNonIncrementingKey() + { + EloquentTestNonIncrementingSecond::insert([ + ['name' => ' First'], + ['name' => ' Second'], + ['name' => ' Third'], + ]); + + $i = 0; + EloquentTestNonIncrementingSecond::query()->chunkById(2, function (Collection $users) use (&$i) { + if (! $i) { + $this->assertSame(' First', $users[0]->name); + $this->assertSame(' Second', $users[1]->name); + } else { + $this->assertSame(' Third', $users[0]->name); + } + $i++; + }, 'name'); + $this->assertEquals(2, $i); + } + + public function testEachByIdWithNonIncrementingKey() + { + EloquentTestNonIncrementingSecond::insert([ + ['name' => ' First'], + ['name' => ' Second'], + ['name' => ' Third'], + ]); + + $users = []; + EloquentTestNonIncrementingSecond::query()->eachById( + function (EloquentTestNonIncrementingSecond $user, $i) use (&$users) { + $users[] = [$user->name, $i]; + }, 2, 'name'); + $this->assertSame([[' First', 0], [' Second', 1], [' Third', 2]], $users); + } + + public function testPluck() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ]); + + $simple = EloquentTestUser::oldest('id')->pluck('users.email')->all(); + $keyed = EloquentTestUser::oldest('id')->pluck('users.email', 'users.id')->all(); + + $this->assertEquals(['taylorotwell@gmail.com', 'abigailotwell@gmail.com'], $simple); + $this->assertEquals([1 => 'taylorotwell@gmail.com', 2 => 'abigailotwell@gmail.com'], $keyed); + } + + public function testPluckWithJoin() + { + $user1 = EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $user2 = EloquentTestUser::create(['id' => 2, 'email' => 'abigailotwell@gmail.com']); + + $user2->posts()->create(['id' => 1, 'name' => 'First post']); + $user1->posts()->create(['id' => 2, 'name' => 'Second post']); + + $query = EloquentTestUser::join('posts', 'users.id', '=', 'posts.user_id'); + + $this->assertEquals([1 => 'First post', 2 => 'Second post'], $query->pluck('posts.name', 'posts.id')->all()); + $this->assertEquals([2 => 'First post', 1 => 'Second post'], $query->pluck('posts.name', 'users.id')->all()); + $this->assertEquals(['abigailotwell@gmail.com' => 'First post', 'taylorotwell@gmail.com' => 'Second post'], $query->pluck('posts.name', 'users.email AS user_email')->all()); + } + + public function testPluckWithColumnNameContainingASpace() + { + EloquentTestUserWithSpaceInColumnName::insert([ + ['id' => 1, 'email address' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email address' => 'abigailotwell@gmail.com'], + ]); + + $simple = EloquentTestUserWithSpaceInColumnName::oldest('id')->pluck('users_with_space_in_column_name.email address')->all(); + $keyed = EloquentTestUserWithSpaceInColumnName::oldest('id')->pluck('email address', 'id')->all(); + + $this->assertEquals(['taylorotwell@gmail.com', 'abigailotwell@gmail.com'], $simple); + $this->assertEquals([1 => 'taylorotwell@gmail.com', 2 => 'abigailotwell@gmail.com'], $keyed); + } + + public function testFindOrFail() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ]); + + $single = EloquentTestUser::findOrFail(1); + $multiple = EloquentTestUser::findOrFail([1, 2]); + + $this->assertInstanceOf(EloquentTestUser::class, $single); + $this->assertSame('taylorotwell@gmail.com', $single->email); + $this->assertInstanceOf(Collection::class, $multiple); + $this->assertInstanceOf(EloquentTestUser::class, $multiple[0]); + $this->assertInstanceOf(EloquentTestUser::class, $multiple[1]); + } + + public function testFindOrFailWithSingleIdThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Illuminate\Tests\Database\EloquentTestUser] 1'); + $this->expectExceptionObject( + (new ModelNotFoundException())->setModel(EloquentTestUser::class, [1]), + ); + + EloquentTestUser::findOrFail(1); + } + + public function testFindOrFailWithMultipleIdsThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Illuminate\Tests\Database\EloquentTestUser] 2, 3'); + $this->expectExceptionObject( + (new ModelNotFoundException())->setModel(EloquentTestUser::class, [2, 3]), + ); + + EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + EloquentTestUser::findOrFail([1, 2, 3]); + } + + public function testFindOrFailWithMultipleIdsUsingCollectionThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Illuminate\Tests\Database\EloquentTestUser] 2, 3'); + $this->expectExceptionObject( + (new ModelNotFoundException())->setModel(EloquentTestUser::class, [2, 3]), + ); + + EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + EloquentTestUser::findOrFail(new Collection([1, 1, 2, 3])); + } + + public function testOneToOneRelationship() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->post()->create(['name' => 'First Post']); + + $post = $user->post; + $user = $post->user; + + $this->assertTrue(isset($user->post->name)); + $this->assertInstanceOf(EloquentTestUser::class, $user); + $this->assertInstanceOf(EloquentTestPost::class, $post); + $this->assertSame('taylorotwell@gmail.com', $user->email); + $this->assertSame('First Post', $post->name); + } + + public function testIssetLoadsInRelationshipIfItIsntLoadedAlready() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->post()->create(['name' => 'First Post']); + + $this->assertTrue(isset($user->post->name)); + } + + public function testOneToManyRelationship() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->posts()->create(['name' => 'First Post']); + $user->posts()->create(['name' => 'Second Post']); + + $posts = $user->posts; + $post2 = $user->posts()->where('name', 'Second Post')->first(); + + $this->assertInstanceOf(Collection::class, $posts); + $this->assertCount(2, $posts); + $this->assertInstanceOf(EloquentTestPost::class, $posts[0]); + $this->assertInstanceOf(EloquentTestPost::class, $posts[1]); + $this->assertInstanceOf(EloquentTestPost::class, $post2); + $this->assertSame('Second Post', $post2->name); + $this->assertInstanceOf(EloquentTestUser::class, $post2->user); + $this->assertSame('taylorotwell@gmail.com', $post2->user->email); + } + + public function testBasicModelHydration() + { + $user = new EloquentTestUser(['email' => 'taylorotwell@gmail.com']); + $user->setConnection('second_connection'); + $user->save(); + + $user = new EloquentTestUser(['email' => 'abigailotwell@gmail.com']); + $user->setConnection('second_connection'); + $user->save(); + + $models = EloquentTestUser::on('second_connection')->fromQuery('SELECT * FROM users WHERE email = ?', ['abigailotwell@gmail.com']); + + $this->assertInstanceOf(Collection::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertSame('abigailotwell@gmail.com', $models[0]->email); + $this->assertSame('second_connection', $models[0]->getConnectionName()); + $this->assertCount(1, $models); + } + + public function testFirstOrNewOnHasOneRelationShip() + { + $user1 = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $post1 = $user1->post()->firstOrNew(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('New Post', $post1->name); + + $user2 = EloquentTestUser::create(['email' => 'abigailotwell@gmail.com']); + $post = $user2->post()->create(['name' => 'First Post']); + $post2 = $user2->post()->firstOrNew(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('First Post', $post2->name); + $this->assertSame($post->id, $post2->id); + } + + public function testFirstOrCreateOnHasOneRelationShip() + { + $user1 = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $post1 = $user1->post()->firstOrCreate(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('New Post', $post1->name); + + $user2 = EloquentTestUser::create(['email' => 'abigailotwell@gmail.com']); + $post = $user2->post()->create(['name' => 'First Post']); + $post2 = $user2->post()->firstOrCreate(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('First Post', $post2->name); + $this->assertSame($post->id, $post2->id); + } + + public function testHasOnSelfReferencingBelongsToManyRelationship() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $this->assertTrue(isset($user->friends[0]->id)); + + $results = EloquentTestUser::has('friends')->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testWhereHasOnSelfReferencingBelongsToManyRelationship() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $results = EloquentTestUser::whereHas('friends', function ($query) { + $query->where('email', 'abigailotwell@gmail.com'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testWithWhereHasOnSelfReferencingBelongsToManyRelationship() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $results = EloquentTestUser::withWhereHas('friends', function ($query) { + $query->where('email', 'abigailotwell@gmail.com'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + $this->assertTrue($results->first()->relationLoaded('friends')); + $this->assertSame($results->first()->friends->pluck('email')->unique()->toArray(), ['abigailotwell@gmail.com']); + } + + public function testHasOnNestedSelfReferencingBelongsToManyRelationship() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $friend->friends()->create(['email' => 'foo@gmail.com']); + + $results = EloquentTestUser::has('friends.friends')->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testWhereHasOnNestedSelfReferencingBelongsToManyRelationship() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $friend->friends()->create(['email' => 'foo@gmail.com']); + + $results = EloquentTestUser::whereHas('friends.friends', function ($query) { + $query->where('email', 'foo@gmail.com'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testWithWhereHasOnNestedSelfReferencingBelongsToManyRelationship() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $friend->friends()->create(['email' => 'foo@gmail.com']); + + $results = EloquentTestUser::withWhereHas('friends.friends', function ($query) { + $query->where('email', 'foo@gmail.com'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + $this->assertTrue($results->first()->relationLoaded('friends')); + $this->assertSame($results->first()->friends->pluck('email')->unique()->toArray(), ['abigailotwell@gmail.com']); + $this->assertSame($results->first()->friends->pluck('friends')->flatten()->pluck('email')->unique()->toArray(), ['foo@gmail.com']); + } + + public function testHasOnSelfReferencingBelongsToManyRelationshipWithWherePivot() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $results = EloquentTestUser::has('friendsOne')->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testHasOnNestedSelfReferencingBelongsToManyRelationshipWithWherePivot() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $friend->friends()->create(['email' => 'foo@gmail.com']); + + $results = EloquentTestUser::has('friendsOne.friendsTwo')->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testHasOnSelfReferencingBelongsToRelationship() + { + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'user_id' => 1]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = EloquentTestPost::has('parentPost')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Child Post', $results->first()->name); + } + + public function testAggregatedValuesOfDatetimeField() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'test1@test.test', 'created_at' => '2016-08-10 09:21:00', 'updated_at' => Carbon::now()], + ['id' => 2, 'email' => 'test2@test.test', 'created_at' => '2016-08-01 12:00:00', 'updated_at' => Carbon::now()], + ]); + + $this->assertSame('2016-08-10 09:21:00', EloquentTestUser::max('created_at')); + $this->assertSame('2016-08-01 12:00:00', EloquentTestUser::min('created_at')); + } + + public function testWhereHasOnSelfReferencingBelongsToRelationship() + { + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'user_id' => 1]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = EloquentTestPost::whereHas('parentPost', function ($query) { + $query->where('name', 'Parent Post'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('Child Post', $results->first()->name); + } + + public function testWithWhereHasOnSelfReferencingBelongsToRelationship() + { + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'user_id' => 1]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = EloquentTestPost::withWhereHas('parentPost', function ($query) { + $query->where('name', 'Parent Post'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('Child Post', $results->first()->name); + $this->assertTrue($results->first()->relationLoaded('parentPost')); + $this->assertSame($results->first()->parentPost->name, 'Parent Post'); + } + + public function testHasOnNestedSelfReferencingBelongsToRelationship() + { + $grandParentPost = EloquentTestPost::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = EloquentTestPost::has('parentPost.parentPost')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Child Post', $results->first()->name); + } + + public function testWhereHasOnNestedSelfReferencingBelongsToRelationship() + { + $grandParentPost = EloquentTestPost::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = EloquentTestPost::whereHas('parentPost.parentPost', function ($query) { + $query->where('name', 'Grandparent Post'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('Child Post', $results->first()->name); + } + + public function testWithWhereHasOnNestedSelfReferencingBelongsToRelationship() + { + $grandParentPost = EloquentTestPost::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = EloquentTestPost::withWhereHas('parentPost.parentPost', function ($query) { + $query->where('name', 'Grandparent Post'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('Child Post', $results->first()->name); + $this->assertTrue($results->first()->relationLoaded('parentPost')); + $this->assertSame($results->first()->parentPost->name, 'Parent Post'); + $this->assertTrue($results->first()->parentPost->relationLoaded('parentPost')); + $this->assertSame($results->first()->parentPost->parentPost->name, 'Grandparent Post'); + } + + public function testHasOnSelfReferencingHasManyRelationship() + { + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'user_id' => 1]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = EloquentTestPost::has('childPosts')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Parent Post', $results->first()->name); + } + + public function testWhereHasOnSelfReferencingHasManyRelationship() + { + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'user_id' => 1]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = EloquentTestPost::whereHas('childPosts', function ($query) { + $query->where('name', 'Child Post'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('Parent Post', $results->first()->name); + } + + public function testWithWhereHasOnSelfReferencingHasManyRelationship() + { + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'user_id' => 1]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = EloquentTestPost::withWhereHas('childPosts', function ($query) { + $query->where('name', 'Child Post'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('Parent Post', $results->first()->name); + $this->assertTrue($results->first()->relationLoaded('childPosts')); + $this->assertSame($results->first()->childPosts->pluck('name')->unique()->toArray(), ['Child Post']); + } + + public function testHasOnNestedSelfReferencingHasManyRelationship() + { + $grandParentPost = EloquentTestPost::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = EloquentTestPost::has('childPosts.childPosts')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Grandparent Post', $results->first()->name); + } + + public function testWhereHasOnNestedSelfReferencingHasManyRelationship() + { + $grandParentPost = EloquentTestPost::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = EloquentTestPost::whereHas('childPosts.childPosts', function ($query) { + $query->where('name', 'Child Post'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('Grandparent Post', $results->first()->name); + } + + public function testWithWhereHasOnNestedSelfReferencingHasManyRelationship() + { + $grandParentPost = EloquentTestPost::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = EloquentTestPost::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + EloquentTestPost::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = EloquentTestPost::withWhereHas('childPosts.childPosts', function ($query) { + $query->where('name', 'Child Post'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('Grandparent Post', $results->first()->name); + $this->assertTrue($results->first()->relationLoaded('childPosts')); + $this->assertSame($results->first()->childPosts->pluck('name')->unique()->toArray(), ['Parent Post']); + $this->assertSame($results->first()->childPosts->pluck('childPosts')->flatten()->pluck('name')->unique()->toArray(), ['Child Post']); + } + + public function testHasWithNonWhereBindings() + { + $user = EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + + $user->posts()->create(['name' => 'Post 2']) + ->photos()->create(['name' => 'photo.jpg']); + + $query = EloquentTestUser::has('postWithPhotos'); + + $bindingsCount = count($query->getBindings()); + $questionMarksCount = substr_count($query->toSql(), '?'); + + $this->assertEquals($questionMarksCount, $bindingsCount); + } + + public function testHasOnMorphToRelationship() + { + $post = EloquentTestPost::create(['name' => 'Morph Post', 'user_id' => 1]); + (new EloquentTestPhoto)->imageable()->associate($post)->fill(['name' => 'Morph Photo'])->save(); + + $photos = EloquentTestPhoto::has('imageable')->get(); + + $this->assertEquals(1, $photos->count()); + } + + public function testBelongsToManyRelationshipModelsAreProperlyHydratedWithSoleQuery() + { + $user = EloquentTestUserWithCustomFriendPivot::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $user->friends()->get()->each(function ($friend) { + $this->assertInstanceOf(EloquentTestFriendPivot::class, $friend->pivot); + }); + + $soleFriend = $user->friends()->where('email', 'abigailotwell@gmail.com')->sole(); + + $this->assertInstanceOf(EloquentTestFriendPivot::class, $soleFriend->pivot); + } + + public function testBelongsToManyRelationshipMissingModelExceptionWithSoleQueryWorks() + { + $this->expectException(ModelNotFoundException::class); + $user = EloquentTestUserWithCustomFriendPivot::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->where('email', 'abigailotwell@gmail.com')->sole(); + } + + public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverChunkedRequest() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + EloquentTestUser::first()->friends()->chunk(2, function ($friends) use ($user, $friend) { + $this->assertCount(1, $friends); + $this->assertSame('abigailotwell@gmail.com', $friends->first()->email); + $this->assertEquals($user->id, $friends->first()->pivot->user_id); + $this->assertEquals($friend->id, $friends->first()->pivot->friend_id); + }); + } + + public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverEachRequest() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + EloquentTestUser::first()->friends()->each(function ($result) use ($user, $friend) { + $this->assertSame('abigailotwell@gmail.com', $result->email); + $this->assertEquals($user->id, $result->pivot->user_id); + $this->assertEquals($friend->id, $result->pivot->friend_id); + }); + } + + public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverCursorRequest() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + foreach (EloquentTestUser::first()->friends()->cursor() as $result) { + $this->assertSame('abigailotwell@gmail.com', $result->email); + $this->assertEquals($user->id, $result->pivot->user_id); + $this->assertEquals($friend->id, $result->pivot->friend_id); + } + } + + public function testWhereAttachedTo() + { + EloquentTestUser::insert([ + ['email' => 'user1@gmail.com'], + ['email' => 'user2@gmail.com'], + ['email' => 'user3@gmail.com'], + ]); + + [$user1, $user2, $user3] = EloquentTestUser::get(); + + EloquentTestAchievement::fillAndInsert([['status' => 3], [], []]); + [$achievement1, $achievement2, $achievement3] = EloquentTestAchievement::get(); + + $user1->eloquentTestAchievements()->attach([$achievement1]); + $user2->eloquentTestAchievements()->attach([$achievement1, $achievement3]); + $user3->eloquentTestAchievements()->attach([$achievement2, $achievement3]); + + $achievedAchievement1 = EloquentTestUser::whereAttachedTo($achievement1)->get(); + + $this->assertSame(2, $achievedAchievement1->count()); + $this->assertTrue($achievedAchievement1->contains($user1)); + $this->assertTrue($achievedAchievement1->contains($user2)); + + $achievedByUser1or2 = EloquentTestAchievement::whereAttachedTo( + new Collection([$user1, $user2]) + )->get(); + + $this->assertSame(2, $achievedByUser1or2->count()); + $this->assertTrue($achievedByUser1or2->contains($achievement1)); + $this->assertTrue($achievedByUser1or2->contains($achievement3)); + } + + public function testBasicHasManyEagerLoading() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->posts()->create(['name' => 'First Post']); + $user = EloquentTestUser::with('posts')->where('email', 'taylorotwell@gmail.com')->first(); + + $this->assertSame('First Post', $user->posts->first()->name); + + $post = EloquentTestPost::with('user')->where('name', 'First Post')->get(); + $this->assertSame('taylorotwell@gmail.com', $post->first()->user->email); + } + + public function testBasicNestedSelfReferencingHasManyEagerLoading() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $post = $user->posts()->create(['name' => 'First Post']); + $post->childPosts()->create(['name' => 'Child Post', 'user_id' => $user->id]); + + $user = EloquentTestUser::with('posts.childPosts')->where('email', 'taylorotwell@gmail.com')->first(); + + $this->assertNotNull($user->posts->first()); + $this->assertSame('First Post', $user->posts->first()->name); + + $this->assertNotNull($user->posts->first()->childPosts->first()); + $this->assertSame('Child Post', $user->posts->first()->childPosts->first()->name); + + $post = EloquentTestPost::with('parentPost.user')->where('name', 'Child Post')->get(); + $this->assertNotNull($post->first()->parentPost); + $this->assertNotNull($post->first()->parentPost->user); + $this->assertSame('taylorotwell@gmail.com', $post->first()->parentPost->user->email); + } + + public function testBasicMorphManyRelationship() + { + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->photos()->create(['name' => 'Avatar 1']); + $user->photos()->create(['name' => 'Avatar 2']); + $post = $user->posts()->create(['name' => 'First Post']); + $post->photos()->create(['name' => 'Hero 1']); + $post->photos()->create(['name' => 'Hero 2']); + + $this->assertInstanceOf(Collection::class, $user->photos); + $this->assertInstanceOf(EloquentTestPhoto::class, $user->photos[0]); + $this->assertInstanceOf(Collection::class, $post->photos); + $this->assertInstanceOf(EloquentTestPhoto::class, $post->photos[0]); + $this->assertCount(2, $user->photos); + $this->assertCount(2, $post->photos); + $this->assertSame('Avatar 1', $user->photos[0]->name); + $this->assertSame('Avatar 2', $user->photos[1]->name); + $this->assertSame('Hero 1', $post->photos[0]->name); + $this->assertSame('Hero 2', $post->photos[1]->name); + + $photos = EloquentTestPhoto::orderBy('name')->get(); + + $this->assertInstanceOf(Collection::class, $photos); + $this->assertCount(4, $photos); + $this->assertInstanceOf(EloquentTestUser::class, $photos[0]->imageable); + $this->assertInstanceOf(EloquentTestPost::class, $photos[2]->imageable); + $this->assertSame('taylorotwell@gmail.com', $photos[1]->imageable->email); + $this->assertSame('First Post', $photos[3]->imageable->name); + } + + public function testMorphMapIsUsedForCreatingAndFetchingThroughRelation() + { + Relation::morphMap([ + 'user' => EloquentTestUser::class, + 'post' => EloquentTestPost::class, + ]); + + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->photos()->create(['name' => 'Avatar 1']); + $user->photos()->create(['name' => 'Avatar 2']); + $post = $user->posts()->create(['name' => 'First Post']); + $post->photos()->create(['name' => 'Hero 1']); + $post->photos()->create(['name' => 'Hero 2']); + + $this->assertInstanceOf(Collection::class, $user->photos); + $this->assertInstanceOf(EloquentTestPhoto::class, $user->photos[0]); + $this->assertInstanceOf(Collection::class, $post->photos); + $this->assertInstanceOf(EloquentTestPhoto::class, $post->photos[0]); + $this->assertCount(2, $user->photos); + $this->assertCount(2, $post->photos); + $this->assertSame('Avatar 1', $user->photos[0]->name); + $this->assertSame('Avatar 2', $user->photos[1]->name); + $this->assertSame('Hero 1', $post->photos[0]->name); + $this->assertSame('Hero 2', $post->photos[1]->name); + + $this->assertSame('user', $user->photos[0]->imageable_type); + $this->assertSame('user', $user->photos[1]->imageable_type); + $this->assertSame('post', $post->photos[0]->imageable_type); + $this->assertSame('post', $post->photos[1]->imageable_type); + } + + public function testMorphMapIsUsedWhenFetchingParent() + { + Relation::morphMap([ + 'user' => EloquentTestUser::class, + 'post' => EloquentTestPost::class, + ]); + + $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + $user->photos()->create(['name' => 'Avatar 1']); + + $photo = EloquentTestPhoto::first(); + $this->assertSame('user', $photo->imageable_type); + $this->assertInstanceOf(EloquentTestUser::class, $photo->imageable); + } + + public function testMorphMapIsMergedByDefault() + { + $map1 = [ + 'user' => EloquentTestUser::class, + ]; + $map2 = [ + 'post' => EloquentTestPost::class, + ]; + + Relation::morphMap($map1); + Relation::morphMap($map2); + + $this->assertEquals(array_merge($map1, $map2), Relation::morphMap()); + } + + public function testMorphMapOverwritesCurrentMap() + { + $map1 = [ + 'user' => EloquentTestUser::class, + ]; + $map2 = [ + 'post' => EloquentTestPost::class, + ]; + + Relation::morphMap($map1, false); + $this->assertEquals($map1, Relation::morphMap()); + Relation::morphMap($map2, false); + $this->assertEquals($map2, Relation::morphMap()); + } + + public function testEmptyMorphToRelationship() + { + $photo = new EloquentTestPhoto; + + $this->assertNull($photo->imageable); + } + + public function testSaveOrFail() + { + $date = '1970-01-01'; + $post = new EloquentTestPost([ + 'user_id' => 1, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date, + ]); + + $this->assertTrue($post->saveOrFail()); + $this->assertEquals(1, EloquentTestPost::count()); + } + + public function testSavingJSONFields() + { + $model = EloquentTestWithJSON::create(['json' => ['x' => 0]]); + $this->assertEquals(['x' => 0], $model->json); + + $model->fillable(['json->y', 'json->a->b']); + + $model->update(['json->y' => '1']); + $this->assertArrayNotHasKey('json->y', $model->toArray()); + $this->assertEquals(['x' => 0, 'y' => 1], $model->json); + + $model->update(['json->a->b' => '3']); + $this->assertArrayNotHasKey('json->a->b', $model->toArray()); + $this->assertEquals(['x' => 0, 'y' => 1, 'a' => ['b' => 3]], $model->json); + } + + public function testSaveOrFailWithDuplicatedEntry() + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('SQLSTATE[23000]:'); + + $date = '1970-01-01'; + EloquentTestPost::create([ + 'id' => 1, 'user_id' => 1, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date, + ]); + + $post = new EloquentTestPost([ + 'id' => 1, 'user_id' => 1, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date, + ]); + + $post->saveOrFail(); + } + + public function testMultiInsertsWithDifferentValues() + { + $date = '1970-01-01'; + $result = EloquentTestPost::insert([ + ['user_id' => 1, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date], + ['user_id' => 2, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date], + ]); + + $this->assertTrue($result); + $this->assertEquals(2, EloquentTestPost::count()); + } + + public function testMultiInsertsWithSameValues() + { + $date = '1970-01-01'; + $result = EloquentTestPost::insert([ + ['user_id' => 1, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date], + ['user_id' => 1, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date], + ]); + + $this->assertTrue($result); + $this->assertEquals(2, EloquentTestPost::count()); + } + + public function testNestedTransactions() + { + $user = EloquentTestUser::create(['email' => 'taylor@laravel.com']); + $this->connection()->transaction(function () use ($user) { + try { + $this->connection()->transaction(function () use ($user) { + $user->email = 'otwell@laravel.com'; + $user->save(); + throw new Exception; + }); + } catch (Exception) { + // ignore the exception + } + $user = EloquentTestUser::first(); + $this->assertSame('taylor@laravel.com', $user->email); + }); + } + + public function testNestedTransactionsUsingSaveOrFailWillSucceed() + { + $user = EloquentTestUser::create(['email' => 'taylor@laravel.com']); + $this->connection()->transaction(function () use ($user) { + try { + $user->email = 'otwell@laravel.com'; + $user->saveOrFail(); + } catch (Exception) { + // ignore the exception + } + + $user = EloquentTestUser::first(); + $this->assertSame('otwell@laravel.com', $user->email); + $this->assertEquals(1, $user->id); + }); + } + + public function testNestedTransactionsUsingSaveOrFailWillFails() + { + $user = EloquentTestUser::create(['email' => 'taylor@laravel.com']); + $this->connection()->transaction(function () use ($user) { + try { + $user->id = 'invalid'; + $user->email = 'otwell@laravel.com'; + $user->saveOrFail(); + } catch (Exception) { + // ignore the exception + } + + $user = EloquentTestUser::first(); + $this->assertSame('taylor@laravel.com', $user->email); + $this->assertEquals(1, $user->id); + }); + } + + public function testToArrayIncludesDefaultFormattedTimestamps() + { + $model = new EloquentTestUser; + + $model->setRawAttributes([ + 'created_at' => '2012-12-04', + 'updated_at' => '2012-12-05', + ]); + + $array = $model->toArray(); + + $this->assertSame('2012-12-04T00:00:00.000000Z', $array['created_at']); + $this->assertSame('2012-12-05T00:00:00.000000Z', $array['updated_at']); + } + + public function testToArrayIncludesCustomFormattedTimestamps() + { + $model = new EloquentTestUserWithCustomDateSerialization; + + $model->setRawAttributes([ + 'created_at' => '2012-12-04', + 'updated_at' => '2012-12-05', + ]); + + $array = $model->toArray(); + + $this->assertSame('04-12-12', $array['created_at']); + $this->assertSame('05-12-12', $array['updated_at']); + } + + public function testIncrementingPrimaryKeysAreCastToIntegersByDefault() + { + EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + + $user = EloquentTestUser::first(); + $this->assertIsInt($user->id); + } + + public function testDefaultIncrementingPrimaryKeyIntegerCastCanBeOverwritten() + { + EloquentTestUserWithStringCastId::create(['email' => 'taylorotwell@gmail.com']); + + $user = EloquentTestUserWithStringCastId::first(); + $this->assertIsString($user->id); + } + + public function testRelationsArePreloadedInGlobalScope() + { + $user = EloquentTestUserWithGlobalScope::create(['email' => 'taylorotwell@gmail.com']); + $user->posts()->create(['name' => 'My Post']); + + $result = EloquentTestUserWithGlobalScope::first(); + + $this->assertCount(1, $result->getRelations()); + } + + public function testModelIgnoredByGlobalScopeCanBeRefreshed() + { + $user = EloquentTestUserWithOmittingGlobalScope::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + + $this->assertNotNull($user->fresh()); + } + + public function testGlobalScopeCanBeRemovedByOtherGlobalScope() + { + $user = EloquentTestUserWithGlobalScopeRemovingOtherScope::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $user->delete(); + + $this->assertNotNull(EloquentTestUserWithGlobalScopeRemovingOtherScope::find($user->id)); + } + + public function testForPageBeforeIdCorrectlyPaginates() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ]); + + $results = EloquentTestUser::forPageBeforeId(15, 2); + $this->assertInstanceOf(Builder::class, $results); + $this->assertEquals(1, $results->first()->id); + + $results = EloquentTestUser::orderBy('id', 'desc')->forPageBeforeId(15, 2); + $this->assertInstanceOf(Builder::class, $results); + $this->assertEquals(1, $results->first()->id); + } + + public function testForPageAfterIdCorrectlyPaginates() + { + EloquentTestUser::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ]); + + $results = EloquentTestUser::forPageAfterId(15, 1); + $this->assertInstanceOf(Builder::class, $results); + $this->assertEquals(2, $results->first()->id); + + $results = EloquentTestUser::orderBy('id', 'desc')->forPageAfterId(15, 1); + $this->assertInstanceOf(Builder::class, $results); + $this->assertEquals(2, $results->first()->id); + } + + public function testMorphToRelationsAcrossDatabaseConnections() + { + $item = null; + + EloquentTestItem::create(['id' => 1]); + EloquentTestOrder::create(['id' => 1, 'item_type' => EloquentTestItem::class, 'item_id' => 1]); + try { + $item = EloquentTestOrder::first()->item; + } catch (Exception) { + // ignore the exception + } + + $this->assertInstanceOf(EloquentTestItem::class, $item); + } + + public function testEagerLoadedMorphToRelationsOnAnotherDatabaseConnection() + { + EloquentTestPost::create(['id' => 1, 'name' => 'Default Connection Post', 'user_id' => 1]); + EloquentTestPhoto::create(['id' => 1, 'imageable_type' => EloquentTestPost::class, 'imageable_id' => 1, 'name' => 'Photo']); + + EloquentTestPost::on('second_connection') + ->create(['id' => 1, 'name' => 'Second Connection Post', 'user_id' => 1]); + EloquentTestPhoto::on('second_connection') + ->create(['id' => 1, 'imageable_type' => EloquentTestPost::class, 'imageable_id' => 1, 'name' => 'Photo']); + + $defaultConnectionPost = EloquentTestPhoto::with('imageable')->first()->imageable; + $secondConnectionPost = EloquentTestPhoto::on('second_connection')->with('imageable')->first()->imageable; + + $this->assertSame('Default Connection Post', $defaultConnectionPost->name); + $this->assertSame('Second Connection Post', $secondConnectionPost->name); + } + + public function testBelongsToManyCustomPivot() + { + $john = EloquentTestUserWithCustomFriendPivot::create(['id' => 1, 'name' => 'John Doe', 'email' => 'johndoe@example.com']); + $jane = EloquentTestUserWithCustomFriendPivot::create(['id' => 2, 'name' => 'Jane Doe', 'email' => 'janedoe@example.com']); + $jack = EloquentTestUserWithCustomFriendPivot::create(['id' => 3, 'name' => 'Jack Doe', 'email' => 'jackdoe@example.com']); + $jule = EloquentTestUserWithCustomFriendPivot::create(['id' => 4, 'name' => 'Jule Doe', 'email' => 'juledoe@example.com']); + + EloquentTestFriendLevel::insert([ + ['id' => 1, 'level' => 'acquaintance'], + ['id' => 2, 'level' => 'friend'], + ['id' => 3, 'level' => 'bff'], + ]); + + $john->friends()->attach($jane, ['friend_level_id' => 1]); + $john->friends()->attach($jack, ['friend_level_id' => 2]); + $john->friends()->attach($jule, ['friend_level_id' => 3]); + + $johnWithFriends = EloquentTestUserWithCustomFriendPivot::with('friends')->find(1); + + $this->assertCount(3, $johnWithFriends->friends); + $this->assertSame('friend', $johnWithFriends->friends->find(3)->pivot->level->level); + $this->assertSame('Jule Doe', $johnWithFriends->friends->find(4)->pivot->friend->name); + } + + public function testIsAfterRetrievingTheSameModel() + { + $saved = EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $retrieved = EloquentTestUser::find(1); + + $this->assertTrue($saved->is($retrieved)); + } + + public function testFreshMethodOnModel() + { + $now = Carbon::now()->startOfSecond(); + $nowSerialized = $now->toJSON(); + $nowWithFractionsSerialized = $now->toJSON(); + Carbon::setTestNow($now); + + $storedUser1 = EloquentTestUser::create([ + 'id' => 1, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $now, + ]); + $storedUser1->newQuery()->update([ + 'email' => 'dev@mathieutu.ovh', + 'name' => 'Mathieu TUDISCO', + ]); + $freshStoredUser1 = $storedUser1->fresh(); + + $storedUser2 = EloquentTestUser::create([ + 'id' => 2, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $now, + ]); + $storedUser2->newQuery()->update(['email' => 'dev@mathieutu.ovh']); + $freshStoredUser2 = $storedUser2->fresh(); + + $notStoredUser = new EloquentTestUser([ + 'id' => 3, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $now, + ]); + $freshNotStoredUser = $notStoredUser->fresh(); + + $this->assertEquals([ + 'id' => 1, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $nowWithFractionsSerialized, + 'created_at' => $nowSerialized, + 'updated_at' => $nowSerialized, + ], $storedUser1->toArray()); + $this->assertEquals([ + 'id' => 1, + 'name' => 'Mathieu TUDISCO', + 'email' => 'dev@mathieutu.ovh', + 'birthday' => $nowWithFractionsSerialized, + 'created_at' => $nowSerialized, + 'updated_at' => $nowSerialized, + ], $freshStoredUser1->toArray()); + $this->assertInstanceOf(EloquentTestUser::class, $storedUser1); + + $this->assertEquals([ + 'id' => 2, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $nowWithFractionsSerialized, + 'created_at' => $nowSerialized, + 'updated_at' => $nowSerialized, + ], $storedUser2->toArray()); + $this->assertEquals([ + 'id' => 2, + 'name' => null, + 'email' => 'dev@mathieutu.ovh', + 'birthday' => $nowWithFractionsSerialized, + 'created_at' => $nowSerialized, + 'updated_at' => $nowSerialized, + ], $freshStoredUser2->toArray()); + $this->assertInstanceOf(EloquentTestUser::class, $storedUser2); + + $this->assertEquals([ + 'id' => 3, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $nowWithFractionsSerialized, + ], $notStoredUser->toArray()); + $this->assertNull($freshNotStoredUser); + } + + public function testFreshMethodOnCollection() + { + EloquentTestUser::insert([['id' => 1, 'email' => 'taylorotwell@gmail.com'], ['id' => 2, 'email' => 'taylorotwell@gmail.com']]); + + $users = EloquentTestUser::all() + ->add(new EloquentTestUser(['id' => 3, 'email' => 'taylorotwell@gmail.com'])); + + EloquentTestUser::find(1)->update(['name' => 'Mathieu TUDISCO']); + EloquentTestUser::find(2)->update(['email' => 'dev@mathieutu.ovh']); + + $this->assertCount(3, $users); + $this->assertNotSame('Mathieu TUDISCO', $users[0]->name); + $this->assertNotSame('dev@mathieutu.ovh', $users[1]->email); + + $refreshedUsers = $users->fresh(); + + $this->assertCount(2, $refreshedUsers); + $this->assertSame('Mathieu TUDISCO', $refreshedUsers[0]->name); + $this->assertSame('dev@mathieutu.ovh', $refreshedUsers[1]->email); + } + + public function testTimestampsUsingDefaultDateFormat() + { + $model = new EloquentTestUser; + $model->setDateFormat('Y-m-d H:i:s'); // Default MySQL/PostgreSQL/SQLite date format + $model->setRawAttributes([ + 'created_at' => '2017-11-14 08:23:19', + ]); + + $this->assertSame('2017-11-14 08:23:19', $model->fromDateTime($model->getAttribute('created_at'))); + } + + public function testTimestampsUsingDefaultSqlServerDateFormat() + { + $model = new EloquentTestUser; + $model->setDateFormat('Y-m-d H:i:s.v'); // Default SQL Server date format + $model->setRawAttributes([ + 'created_at' => '2017-11-14 08:23:19.000', + 'updated_at' => '2017-11-14 08:23:19.734', + ]); + + $this->assertSame('2017-11-14 08:23:19.000', $model->fromDateTime($model->getAttribute('created_at'))); + $this->assertSame('2017-11-14 08:23:19.734', $model->fromDateTime($model->getAttribute('updated_at'))); + } + + public function testTimestampsUsingCustomDateFormat() + { + // Simulating using custom precisions with timestamps(4) + $model = new EloquentTestUser; + $model->setDateFormat('Y-m-d H:i:s.u'); // Custom date format + $model->setRawAttributes([ + 'created_at' => '2017-11-14 08:23:19.0000', + 'updated_at' => '2017-11-14 08:23:19.7348', + ]); + + // Note: when storing databases would truncate the value to the given precision + $this->assertSame('2017-11-14 08:23:19.000000', $model->fromDateTime($model->getAttribute('created_at'))); + $this->assertSame('2017-11-14 08:23:19.734800', $model->fromDateTime($model->getAttribute('updated_at'))); + } + + public function testTimestampsUsingOldSqlServerDateFormat() + { + $model = new EloquentTestUser; + $model->setDateFormat('Y-m-d H:i:s.000'); // Old SQL Server date format + $model->setRawAttributes([ + 'created_at' => '2017-11-14 08:23:19.000', + ]); + + $this->assertSame('2017-11-14 08:23:19.000', $model->fromDateTime($model->getAttribute('created_at'))); + } + + public function testTimestampsUsingOldSqlServerDateFormatFallbackToDefaultParsing() + { + $model = new EloquentTestUser; + $model->setDateFormat('Y-m-d H:i:s.000'); // Old SQL Server date format + $model->setRawAttributes([ + 'updated_at' => '2017-11-14 08:23:19.734', + ]); + + $date = $model->getAttribute('updated_at'); + $this->assertSame('2017-11-14 08:23:19.734', $date->format('Y-m-d H:i:s.v'), 'the date should contains the precision'); + $this->assertSame('2017-11-14 08:23:19.000', $model->fromDateTime($date), 'the format should trims it'); + // No longer throwing exception since Laravel 7, + // but Date::hasFormat() can be used instead to check date formatting: + $this->assertTrue(Date::hasFormat('2017-11-14 08:23:19.000', $model->getDateFormat())); + $this->assertFalse(Date::hasFormat('2017-11-14 08:23:19.734', $model->getDateFormat())); + } + + public function testSpecialFormats() + { + $model = new EloquentTestUser; + $model->setDateFormat('!Y-d-m \\Y'); + $model->setRawAttributes([ + 'updated_at' => '2017-05-11 Y', + ]); + + $date = $model->getAttribute('updated_at'); + $this->assertSame('2017-11-05 00:00:00.000000', $date->format('Y-m-d H:i:s.u'), 'the date should respect the whole format'); + + $model->setDateFormat('Y d m|'); + $model->setRawAttributes([ + 'updated_at' => '2020 11 09', + ]); + + $date = $model->getAttribute('updated_at'); + $this->assertSame('2020-09-11 00:00:00.000000', $date->format('Y-m-d H:i:s.u'), 'the date should respect the whole format'); + + $model->setDateFormat('Y d m|*'); + $model->setRawAttributes([ + 'updated_at' => '2020 11 09 foo', + ]); + + $date = $model->getAttribute('updated_at'); + $this->assertSame('2020-09-11 00:00:00.000000', $date->format('Y-m-d H:i:s.u'), 'the date should respect the whole format'); + } + + public function testUpdatingChildModelTouchesParent() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + $post->update(['name' => 'Updated']); + + $this->assertTrue($future->isSameDay($post->fresh()->updated_at), 'It is not touching model own timestamps.'); + $this->assertTrue($future->isSameDay($user->fresh()->updated_at), 'It is not touching models related timestamps.'); + } + + public function testMultiLevelTouchingWorks() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['id' => 1, 'name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + EloquentTouchingComment::create(['content' => 'Comment content', 'post_id' => 1]); + + $this->assertTrue($future->isSameDay($post->fresh()->updated_at), 'It is not touching models related timestamps.'); + $this->assertTrue($future->isSameDay($user->fresh()->updated_at), 'It is not touching models related timestamps.'); + } + + public function testDeletingChildModelTouchesParentTimestamps() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + $post->delete(); + + $this->assertTrue($future->isSameDay($user->fresh()->updated_at), 'It is not touching models related timestamps.'); + } + + public function testTouchingChildModelUpdatesParentsTimestamps() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['id' => 1, 'name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + $post->touch(); + + $this->assertTrue($future->isSameDay($post->fresh()->updated_at), 'It is not touching model own timestamps.'); + $this->assertTrue($future->isSameDay($user->fresh()->updated_at), 'It is not touching models related timestamps.'); + } + + public function testTouchingChildModelRespectsParentNoTouching() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['id' => 1, 'name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + EloquentTouchingUser::withoutTouching(function () use ($post) { + $post->touch(); + }); + + $this->assertTrue( + $future->isSameDay($post->fresh()->updated_at), + 'It is not touching model own timestamps in withoutTouching scope.' + ); + + $this->assertTrue( + $before->isSameDay($user->fresh()->updated_at), + 'It is touching model own timestamps in withoutTouching scope, when it should not.' + ); + } + + public function testUpdatingChildPostRespectsNoTouchingDefinition() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + EloquentTouchingUser::withoutTouching(function () use ($post) { + $post->update(['name' => 'Updated']); + }); + + $this->assertTrue($future->isSameDay($post->fresh()->updated_at), 'It is not touching model own timestamps when it should.'); + $this->assertTrue($before->isSameDay($user->fresh()->updated_at), 'It is touching models relationships when it should be disabled.'); + } + + public function testUpdatingModelInTheDisabledScopeTouchesItsOwnTimestamps() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + Model::withoutTouching(function () use ($post) { + $post->update(['name' => 'Updated']); + }); + + $this->assertTrue($future->isSameDay($post->fresh()->updated_at), 'It is touching models when it should be disabled.'); + $this->assertTrue($before->isSameDay($user->fresh()->updated_at), 'It is touching models when it should be disabled.'); + } + + public function testDeletingChildModelRespectsTheNoTouchingRule() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + EloquentTouchingUser::withoutTouching(function () use ($post) { + $post->delete(); + }); + + $this->assertTrue($before->isSameDay($user->fresh()->updated_at), 'It is touching models when it should be disabled.'); + } + + public function testRespectedMultiLevelTouchingChain() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['id' => 1, 'name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + EloquentTouchingUser::withoutTouching(function () { + EloquentTouchingComment::create(['content' => 'Comment content', 'post_id' => 1]); + }); + + $this->assertTrue($future->isSameDay($post->fresh()->updated_at), 'It is touching models when it should be disabled.'); + $this->assertTrue($before->isSameDay($user->fresh()->updated_at), 'It is touching models when it should be disabled.'); + } + + public function testTouchesGreatParentEvenWhenParentIsInNoTouchScope() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['id' => 1, 'name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + EloquentTouchingPost::withoutTouching(function () { + EloquentTouchingComment::create(['content' => 'Comment content', 'post_id' => 1]); + }); + + $this->assertTrue($before->isSameDay($post->fresh()->updated_at), 'It is touching models when it should be disabled.'); + $this->assertTrue($future->isSameDay($user->fresh()->updated_at), 'It is touching models when it should be disabled.'); + } + + public function testCanNestCallsOfNoTouching() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['id' => 1, 'name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + EloquentTouchingUser::withoutTouching(function () { + EloquentTouchingPost::withoutTouching(function () { + EloquentTouchingComment::create(['content' => 'Comment content', 'post_id' => 1]); + }); + }); + + $this->assertTrue($before->isSameDay($post->fresh()->updated_at), 'It is touching models when it should be disabled.'); + $this->assertTrue($before->isSameDay($user->fresh()->updated_at), 'It is touching models when it should be disabled.'); + } + + public function testCanPassArrayOfModelsToIgnore() + { + $before = Carbon::now(); + + $user = EloquentTouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = EloquentTouchingPost::create(['id' => 1, 'name' => 'Parent Post', 'user_id' => 1]); + + $this->assertTrue($before->isSameDay($user->updated_at)); + $this->assertTrue($before->isSameDay($post->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + Model::withoutTouchingOn([EloquentTouchingUser::class, EloquentTouchingPost::class], function () { + EloquentTouchingComment::create(['content' => 'Comment content', 'post_id' => 1]); + }); + + $this->assertTrue($before->isSameDay($post->fresh()->updated_at), 'It is touching models when it should be disabled.'); + $this->assertTrue($before->isSameDay($user->fresh()->updated_at), 'It is touching models when it should be disabled.'); + } + + public function testWhenBaseModelIsIgnoredAllChildModelsAreIgnored() + { + $this->assertFalse(Model::isIgnoringTouch()); + $this->assertFalse(User::isIgnoringTouch()); + + Model::withoutTouching(function () { + $this->assertTrue(Model::isIgnoringTouch()); + $this->assertTrue(User::isIgnoringTouch()); + }); + + $this->assertFalse(User::isIgnoringTouch()); + $this->assertFalse(Model::isIgnoringTouch()); + } + + public function testChildModelsAreIgnored() + { + $this->assertFalse(Model::isIgnoringTouch()); + $this->assertFalse(User::isIgnoringTouch()); + $this->assertFalse(Post::isIgnoringTouch()); + + User::withoutTouching(function () { + $this->assertFalse(Model::isIgnoringTouch()); + $this->assertFalse(Post::isIgnoringTouch()); + $this->assertTrue(User::isIgnoringTouch()); + }); + + $this->assertFalse(Post::isIgnoringTouch()); + $this->assertFalse(User::isIgnoringTouch()); + $this->assertFalse(Model::isIgnoringTouch()); + } + + public function testPivotsCanBeRefreshed() + { + EloquentTestFriendLevel::create(['id' => 1, 'level' => 'acquaintance']); + EloquentTestFriendLevel::create(['id' => 2, 'level' => 'friend']); + + $user = EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['id' => 2, 'email' => 'abigailotwell@gmail.com'], ['friend_level_id' => 1]); + + $pivot = $user->friends[0]->pivot; + + // Simulate a change that happened externally + DB::table('friends')->where('user_id', 1)->where('friend_id', 2)->update([ + 'friend_level_id' => 2, + ]); + + $this->assertInstanceOf(Pivot::class, $freshPivot = $pivot->fresh()); + $this->assertEquals(2, $freshPivot->friend_level_id); + + $this->assertSame($pivot, $pivot->refresh()); + $this->assertEquals(2, $pivot->friend_level_id); + } + + public function testMorphPivotsCanBeRefreshed() + { + $post = EloquentTestPost::create(['name' => 'MorphToMany Post', 'user_id' => 1]); + $post->tags()->create(['id' => 1, 'name' => 'News']); + + $pivot = $post->tags[0]->pivot; + + // Simulate a change that happened externally + DB::table('taggables') + ->where([ + 'taggable_type' => EloquentTestPost::class, + 'taggable_id' => 1, + 'tag_id' => 1, + ]) + ->update([ + 'taxonomy' => 'primary', + ]); + + $this->assertInstanceOf(MorphPivot::class, $freshPivot = $pivot->fresh()); + $this->assertSame('primary', $freshPivot->taxonomy); + + $this->assertSame($pivot, $pivot->refresh()); + $this->assertSame('primary', $pivot->taxonomy); + } + + public function testTouchingChaperonedChildModelUpdatesParentTimestamps() + { + $before = Carbon::now(); + + $one = EloquentTouchingCategory::create(['id' => 1, 'name' => 'One']); + $two = $one->children()->create(['id' => 2, 'name' => 'Two']); + + $this->assertTrue($before->isSameDay($one->updated_at)); + $this->assertTrue($before->isSameDay($two->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + $two->touch(); + + $this->assertTrue($future->isSameDay($two->fresh()->updated_at), 'It is not touching model own timestamps.'); + $this->assertTrue($future->isSameDay($one->fresh()->updated_at), 'It is not touching chaperoned models related timestamps.'); + } + + public function testTouchingBiDirectionalChaperonedModelUpdatesAllRelatedTimestamps() + { + $before = Carbon::now(); + + EloquentTouchingCategory::insert([ + ['id' => 1, 'name' => 'One', 'parent_id' => null, 'created_at' => $before, 'updated_at' => $before], + ['id' => 2, 'name' => 'Two', 'parent_id' => 1, 'created_at' => $before, 'updated_at' => $before], + ['id' => 3, 'name' => 'Three', 'parent_id' => 1, 'created_at' => $before, 'updated_at' => $before], + ['id' => 4, 'name' => 'Four', 'parent_id' => 2, 'created_at' => $before, 'updated_at' => $before], + ]); + + $one = EloquentTouchingCategory::find(1); + [$two, $three] = $one->children; + [$four] = $two->children; + + $this->assertTrue($before->isSameDay($one->updated_at)); + $this->assertTrue($before->isSameDay($two->updated_at)); + $this->assertTrue($before->isSameDay($three->updated_at)); + $this->assertTrue($before->isSameDay($four->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + // Touch a random model and check that all of the others have been updated + $models = tap([$one, $two, $three, $four], shuffle(...)); + $target = array_shift($models); + $target->touch(); + + $this->assertTrue($future->isSameDay($target->fresh()->updated_at), 'It is not touching model own timestamps.'); + + while ($next = array_shift($models)) { + $this->assertTrue( + $future->isSameDay($next->fresh()->updated_at), + 'It is not touching related models timestamps.' + ); + } + } + + public function testCanFillAndInsert() + { + DB::enableQueryLog(); + Carbon::setTestNow('2025-03-15T07:32:00Z'); + + $this->assertTrue(EloquentTestUser::fillAndInsert([ + ['email' => 'taylor@laravel.com', 'birthday' => null], + ['email' => 'nuno@laravel.com', 'birthday' => new Carbon('1980-01-01')], + ['email' => 'tim@laravel.com', 'birthday' => '1987-11-01', 'created_at' => '2025-01-02T02:00:55', 'updated_at' => Carbon::parse('2025-02-19T11:41:13')], + ])); + + $this->assertCount(1, DB::getQueryLog()); + + $this->assertCount(3, $users = EloquentTestUser::get()); + + $users->take(2)->each(function (EloquentTestUser $user) { + $this->assertEquals(Carbon::parse('2025-03-15T07:32:00Z'), $user->created_at); + $this->assertEquals(Carbon::parse('2025-03-15T07:32:00Z'), $user->updated_at); + }); + + $tim = $users->firstWhere('email', 'tim@laravel.com'); + $this->assertEquals(Carbon::parse('2025-01-02T02:00:55'), $tim->created_at); + $this->assertEquals(Carbon::parse('2025-02-19T11:41:13'), $tim->updated_at); + + $this->assertNull($users[0]->birthday); + $this->assertInstanceOf(\DateTime::class, $users[1]->birthday); + $this->assertInstanceOf(\DateTime::class, $users[2]->birthday); + $this->assertEquals('1987-11-01', $users[2]->birthday->format('Y-m-d')); + + DB::flushQueryLog(); + + $this->assertTrue(EloquentTestWithJSON::fillAndInsert([ + ['id' => 1, 'json' => ['album' => 'Keep It Like a Secret', 'release_date' => '1999-02-02']], + ['id' => 2, 'json' => (object) ['album' => 'You In Reverse', 'release_date' => '2006-04-11']], + ])); + + $this->assertCount(1, DB::getQueryLog()); + + $this->assertCount(2, $testsWithJson = EloquentTestWithJSON::get()); + + $testsWithJson->each(function (EloquentTestWithJSON $testWithJson) { + $this->assertIsArray($testWithJson->json); + $this->assertArrayHasKey('album', $testWithJson->json); + }); + } + + public function testCanFillAndInsertWithUniqueStringIds() + { + Str::createUuidsUsingSequence([ + '00000000-0000-7000-0000-000000000000', + '11111111-0000-7000-0000-000000000000', + '22222222-0000-7000-0000-000000000000', + ]); + + $this->assertTrue(ModelWithUniqueStringIds::fillAndInsert([ + [ + 'name' => 'Taylor', 'role' => IntBackedRole::Admin, 'role_string' => StringBackedRole::Admin, + ], + [ + 'name' => 'Nuno', 'role' => 3, 'role_string' => 'admin', + ], + [ + 'name' => 'Dries', 'uuid' => 'bbbb0000-0000-7000-0000-000000000000', + ], + [ + 'name' => 'Chris', + ], + ])); + + $models = ModelWithUniqueStringIds::get(); + + $taylor = $models->firstWhere('name', 'Taylor'); + $nuno = $models->firstWhere('name', 'Nuno'); + $dries = $models->firstWhere('name', 'Dries'); + $chris = $models->firstWhere('name', 'Chris'); + + $this->assertEquals(IntBackedRole::Admin, $taylor->role); + $this->assertEquals(StringBackedRole::Admin, $taylor->role_string); + $this->assertSame('00000000-0000-7000-0000-000000000000', $taylor->uuid); + + $this->assertEquals(IntBackedRole::Admin, $nuno->role); + $this->assertEquals(StringBackedRole::Admin, $nuno->role_string); + $this->assertSame('11111111-0000-7000-0000-000000000000', $nuno->uuid); + + $this->assertEquals(IntBackedRole::User, $dries->role); + $this->assertEquals(StringBackedRole::User, $dries->role_string); + $this->assertSame('bbbb0000-0000-7000-0000-000000000000', $dries->uuid); + + $this->assertEquals(IntBackedRole::User, $chris->role); + $this->assertEquals(StringBackedRole::User, $chris->role_string); + $this->assertSame('22222222-0000-7000-0000-000000000000', $chris->uuid); + } + + public function testFillAndInsertOrIgnore() + { + Str::createUuidsUsingSequence([ + '00000000-0000-7000-0000-000000000000', + '11111111-0000-7000-0000-000000000000', + '22222222-0000-7000-0000-000000000000', + ]); + + $this->assertEquals(1, ModelWithUniqueStringIds::fillAndInsertOrIgnore([ + [ + 'id' => 1, 'name' => 'Taylor', 'role' => IntBackedRole::Admin, 'role_string' => StringBackedRole::Admin, + ], + ])); + + $this->assertSame(1, ModelWithUniqueStringIds::fillAndInsertOrIgnore([ + [ + 'id' => 1, 'name' => 'Taylor', 'role' => IntBackedRole::Admin, 'role_string' => StringBackedRole::Admin, + ], + [ + 'id' => 2, 'name' => 'Nuno', + ], + ])); + + $models = ModelWithUniqueStringIds::get(); + $this->assertSame('00000000-0000-7000-0000-000000000000', $models->firstWhere('name', 'Taylor')->uuid); + $this->assertSame( + ['uuid' => '22222222-0000-7000-0000-000000000000', 'role' => IntBackedRole::User], + $models->firstWhere('name', 'Nuno')->only('uuid', 'role') + ); + } + + public function testFillAndInsertGetId() + { + Str::createUuidsUsingSequence([ + '00000000-0000-7000-0000-000000000000', + ]); + + DB::enableQueryLog(); + + $this->assertIsInt($newId = ModelWithUniqueStringIds::fillAndInsertGetId([ + 'name' => 'Taylor', + 'role' => IntBackedRole::Admin, + 'role_string' => StringBackedRole::Admin, + ])); + $this->assertCount(1, DB::getRawQueryLog()); + $this->assertSame($newId, ModelWithUniqueStringIds::sole()->id); + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class EloquentTestUser extends Eloquent +{ + protected ?string $table = 'users'; + protected array $casts = ['birthday' => 'datetime']; + protected array $guarded = []; + + public function friends() + { + return $this->belongsToMany(self::class, 'friends', 'user_id', 'friend_id'); + } + + public function friendsOne() + { + return $this->belongsToMany(self::class, 'friends', 'user_id', 'friend_id')->wherePivot('user_id', 1); + } + + public function friendsTwo() + { + return $this->belongsToMany(self::class, 'friends', 'user_id', 'friend_id')->wherePivot('user_id', 2); + } + + public function posts() + { + return $this->hasMany(EloquentTestPost::class, 'user_id'); + } + + public function post() + { + return $this->hasOne(EloquentTestPost::class, 'user_id'); + } + + public function photos() + { + return $this->morphMany(EloquentTestPhoto::class, 'imageable'); + } + + public function postWithPhotos() + { + return $this->post()->join('photo', function ($join) { + $join->on('photo.imageable_id', 'post.id'); + $join->where('photo.imageable_type', 'EloquentTestPost'); + }); + } + + public function eloquentTestAchievements() + { + return $this->belongsToMany(EloquentTestAchievement::class); + } +} + +class EloquentTestUserWithCustomFriendPivot extends EloquentTestUser +{ + public function friends() + { + return $this->belongsToMany(EloquentTestUser::class, 'friends', 'user_id', 'friend_id') + ->using(EloquentTestFriendPivot::class)->withPivot('user_id', 'friend_id', 'friend_level_id'); + } +} + +class EloquentTestUserWithSpaceInColumnName extends EloquentTestUser +{ + protected ?string $table = 'users_with_space_in_column_name'; +} + +class EloquentTestNonIncrementing extends Eloquent +{ + protected ?string $table = 'non_incrementing_users'; + protected array $guarded = []; + public bool $incrementing = false; + public bool $timestamps = false; +} + +class EloquentTestNonIncrementingSecond extends EloquentTestNonIncrementing +{ + protected \UnitEnum|string|null $connection = 'second_connection'; +} + +class EloquentTestUserWithGlobalScope extends EloquentTestUser +{ + public static function boot(): void + { + parent::boot(); + + static::addGlobalScope(function ($builder) { + $builder->with('posts'); + }); + } +} + +class EloquentTestUserWithOmittingGlobalScope extends EloquentTestUser +{ + public static function boot(): void + { + parent::boot(); + + static::addGlobalScope(function ($builder) { + $builder->where('email', '!=', 'taylorotwell@gmail.com'); + }); + } +} + +class EloquentTestUserWithGlobalScopeRemovingOtherScope extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'soft_deleted_users'; + + protected array $guarded = []; + + public static function boot(): void + { + static::addGlobalScope(function ($builder) { + $builder->withoutGlobalScope(SoftDeletingScope::class); + }); + + parent::boot(); + } +} + +class EloquentTestUniqueUser extends Eloquent +{ + protected ?string $table = 'unique_users'; + protected array $casts = ['birthday' => 'datetime']; + protected array $guarded = []; +} + +class EloquentTestPost extends Eloquent +{ + protected ?string $table = 'posts'; + protected array $guarded = []; + + public function user() + { + return $this->belongsTo(EloquentTestUser::class, 'user_id'); + } + + public function photos() + { + return $this->morphMany(EloquentTestPhoto::class, 'imageable'); + } + + public function childPosts() + { + return $this->hasMany(self::class, 'parent_id'); + } + + public function parentPost() + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function tags() + { + return $this->morphToMany(EloquentTestTag::class, 'taggable', null, null, 'tag_id')->withPivot('taxonomy'); + } +} + +class EloquentTestTag extends Eloquent +{ + protected ?string $table = 'tags'; + protected array $guarded = []; +} + +class EloquentTestFriendLevel extends Eloquent +{ + protected ?string $table = 'friend_levels'; + protected array $guarded = []; +} + +class EloquentTestPhoto extends Eloquent +{ + protected ?string $table = 'photos'; + protected array $guarded = []; + + public function imageable() + { + return $this->morphTo(); + } +} + +class EloquentTestUserWithStringCastId extends EloquentTestUser +{ + protected array $casts = [ + 'id' => 'string', + ]; +} + +class EloquentTestUserWithCustomDateSerialization extends EloquentTestUser +{ + protected function serializeDate(DateTimeInterface $date): string + { + return $date->format('d-m-y'); + } +} + +class EloquentTestOrder extends Eloquent +{ + protected array $guarded = []; + protected ?string $table = 'test_orders'; + protected array $with = ['item']; + + public function item() + { + return $this->morphTo(); + } +} + +class EloquentTestItem extends Eloquent +{ + protected array $guarded = []; + protected ?string $table = 'test_items'; + protected \UnitEnum|string|null $connection = 'second_connection'; +} + +class EloquentTestWithJSON extends Eloquent +{ + protected array $guarded = []; + protected ?string $table = 'with_json'; + public bool $timestamps = false; + protected array $casts = [ + 'json' => 'array', + ]; +} + +class EloquentTestFriendPivot extends Pivot +{ + protected ?string $table = 'friends'; + protected array $guarded = []; + public bool $timestamps = false; + + public function user() + { + return $this->belongsTo(EloquentTestUser::class); + } + + public function friend() + { + return $this->belongsTo(EloquentTestUser::class); + } + + public function level() + { + return $this->belongsTo(EloquentTestFriendLevel::class, 'friend_level_id'); + } +} + +class EloquentTouchingUser extends Eloquent +{ + protected ?string $table = 'users'; + protected array $guarded = []; +} + +class EloquentTouchingPost extends Eloquent +{ + protected ?string $table = 'posts'; + protected array $guarded = []; + + protected array $touches = [ + 'user', + ]; + + public function user() + { + return $this->belongsTo(EloquentTouchingUser::class, 'user_id'); + } +} + +class EloquentTouchingComment extends Eloquent +{ + protected ?string $table = 'comments'; + protected array $guarded = []; + + protected array $touches = [ + 'post', + ]; + + public function post() + { + return $this->belongsTo(EloquentTouchingPost::class, 'post_id'); + } +} + +class EloquentTouchingCategory extends Eloquent +{ + protected ?string $table = 'categories'; + protected array $guarded = []; + + protected array $touches = [ + 'parent', + 'children', + ]; + + public function parent() + { + return $this->belongsTo(EloquentTouchingCategory::class, 'parent_id'); + } + + public function children() + { + return $this->hasMany(EloquentTouchingCategory::class, 'parent_id')->chaperone(); + } +} + +class EloquentTestAchievement extends Eloquent +{ + public bool $timestamps = false; + + protected ?string $table = 'achievements'; + protected array $guarded = []; + protected array $attributes = ['status' => null]; + + public function eloquentTestUsers() + { + return $this->belongsToMany(EloquentTestUser::class); + } +} + +class ModelWithUniqueStringIds extends Eloquent +{ + use HasUuids; + + public bool $timestamps = false; + + protected ?string $table = 'users_having_uuids'; + + protected array $attributes = [ + 'role' => IntBackedRole::User, + 'role_string' => StringBackedRole::User, + ]; + + protected function casts(): array + { + return [ + 'role' => IntBackedRole::class, + 'role_string' => StringBackedRole::class, + ]; + } + + public function uniqueIds(): array + { + return ['uuid']; + } +} + +enum IntBackedRole: int +{ + case User = 1; + case Admin = 3; +} + +enum StringBackedRole: string +{ + case User = 'user'; + case Admin = 'admin'; +} + +/** + * Local stubs for User and Post (originally from Laravel's Integration\Database\Fixtures) + * Used for isIgnoringTouch() / withoutTouching() tests. + */ +class User extends Eloquent +{ + protected array $guarded = []; +} + +class Post extends Eloquent +{ + protected array $guarded = []; +} diff --git a/tests/Database/Laravel/DatabaseEloquentIntegrationWithTablePrefixTest.php b/tests/Database/Laravel/DatabaseEloquentIntegrationWithTablePrefixTest.php new file mode 100644 index 000000000..23c89225a --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentIntegrationWithTablePrefixTest.php @@ -0,0 +1,177 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + Eloquent::getConnectionResolver()->connection()->setTablePrefix('prefix_'); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema('default')->create('users', function ($table) { + $table->increments('id'); + $table->string('email'); + $table->timestamps(); + }); + + $this->schema('default')->create('friends', function ($table) { + $table->integer('user_id'); + $table->integer('friend_id'); + }); + + $this->schema('default')->create('posts', function ($table) { + $table->increments('id'); + $table->integer('user_id'); + $table->integer('parent_id')->nullable(); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema('default')->create('photos', function ($table) { + $table->increments('id'); + $table->morphs('imageable'); + $table->string('name'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + foreach (['default'] as $connection) { + $this->schema($connection)->drop('users'); + $this->schema($connection)->drop('friends'); + $this->schema($connection)->drop('posts'); + $this->schema($connection)->drop('photos'); + } + + Relation::morphMap([], false); + + parent::tearDown(); + } + + public function testBasicModelHydration() + { + EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); + EloquentTestUser::create(['email' => 'abigailotwell@gmail.com']); + + $models = EloquentTestUser::fromQuery('SELECT * FROM prefix_users WHERE email = ?', ['abigailotwell@gmail.com']); + + $this->assertInstanceOf(Collection::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertSame('abigailotwell@gmail.com', $models[0]->email); + $this->assertCount(1, $models); + } + + public function testTablePrefixWithClonedConnection() + { + $originalConnection = $this->connection(); + $originalPrefix = $originalConnection->getTablePrefix(); + + $clonedConnection = clone $originalConnection; + $clonedConnection->setTablePrefix('cloned_'); + + $this->assertSame($originalPrefix, $originalConnection->getTablePrefix()); + $this->assertSame('cloned_', $clonedConnection->getTablePrefix()); + + $clonedConnection->getSchemaBuilder()->create('test_table', function ($table) { + $table->increments('id'); + $table->string('name'); + }); + + $this->assertTrue($clonedConnection->getSchemaBuilder()->hasTable('test_table')); + $query = $clonedConnection->table('test_table')->toSql(); + $this->assertStringContainsString('cloned_test_table', $query); + + $clonedConnection->getSchemaBuilder()->drop('test_table'); + } + + public function testQueryGrammarUsesCorrectPrefixAfterCloning() + { + $originalConnection = $this->connection(); + + $clonedConnection = clone $originalConnection; + $clonedConnection->setTablePrefix('new_prefix_'); + + $selectSql = $clonedConnection->table('users')->toSql(); + $this->assertStringContainsString('new_prefix_users', $selectSql); + + $insertSql = $clonedConnection->table('users')->toSql(); + $this->assertStringContainsString('new_prefix_users', $insertSql); + + $updateSql = $clonedConnection->table('users')->where('id', 1)->toSql(); + $this->assertStringContainsString('new_prefix_users', $updateSql); + + $deleteSql = $clonedConnection->table('users')->where('id', 1)->toSql(); + $this->assertStringContainsString('new_prefix_users', $deleteSql); + + $originalSql = $originalConnection->table('users')->toSql(); + $this->assertStringContainsString('prefix_users', $originalSql); + $this->assertStringNotContainsString('new_prefix_users', $originalSql); + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class EloquentTestUser extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $guarded = []; +} diff --git a/tests/Database/Laravel/DatabaseEloquentInverseRelationHasManyTest.php b/tests/Database/Laravel/DatabaseEloquentInverseRelationHasManyTest.php new file mode 100755 index 000000000..f6578ffc1 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationHasManyTest.php @@ -0,0 +1,317 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_users', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_posts', function ($table) { + $table->increments('id'); + $table->foreignId('user_id'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_users'); + $this->schema()->drop('test_posts'); + + parent::tearDown(); + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::all(); + + foreach ($users as $user) { + $this->assertFalse($user->relationLoaded('posts')); + foreach ($user->posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::with('posts')->get(); + + foreach ($users as $user) { + $posts = $user->getRelation('posts'); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + } + + public function testHasLatestOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::all(); + + foreach ($users as $user) { + $this->assertFalse($user->relationLoaded('lastPost')); + $post = $user->lastPost; + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasLatestOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::with('lastPost')->get(); + + foreach ($users as $user) { + $post = $user->getRelation('lastPost'); + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testOneOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::all(); + + foreach ($users as $user) { + $this->assertFalse($user->relationLoaded('firstPost')); + $post = $user->firstPost; + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testOneOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::with('firstPost')->get(); + + foreach ($users as $user) { + $post = $user->getRelation('firstPost'); + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenMakingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = $user->posts()->makeMany(array_fill(0, 3, [])); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenCreatingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = $user->posts()->createMany(array_fill(0, 3, [])); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenCreatingManyQuietly() + { + $user = HasManyInverseUserModel::create(); + + $posts = $user->posts()->createManyQuietly(array_fill(0, 3, [])); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenSavingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = array_fill(0, 3, new HasManyInversePostModel); + + $user->posts()->saveMany($posts); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenUpdatingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = HasManyInversePostModel::factory()->count(3)->create(); + + foreach ($posts as $post) { + $this->assertTrue($user->isNot($post->user)); + } + + $user->posts()->saveMany($posts); + + foreach ($posts as $post) { + $this->assertSame($user, $post->user); + } + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class HasManyInverseUserModel extends Model +{ + use HasFactory; + + protected ?string $table = 'test_users'; + + protected array $fillable = ['id']; + + protected static function newFactory(): HasManyInverseUserModelFactory + { + return new HasManyInverseUserModelFactory(); + } + + public function posts(): HasMany + { + return $this->hasMany(HasManyInversePostModel::class, 'user_id')->inverse('user'); + } + + public function lastPost(): HasOne + { + return $this->hasOne(HasManyInversePostModel::class, 'user_id')->latestOfMany()->inverse('user'); + } + + public function firstPost(): HasOne + { + return $this->posts()->one(); + } +} + +class HasManyInverseUserModelFactory extends Factory +{ + protected ?string $model = HasManyInverseUserModel::class; + + public function definition(): array + { + return []; + } + + public function withPosts(int $count = 3): static + { + return $this->afterCreating(function (HasManyInverseUserModel $model) use ($count) { + HasManyInversePostModel::factory()->recycle($model)->count($count)->create(); + }); + } +} + +class HasManyInversePostModel extends Model +{ + use HasFactory; + + protected ?string $table = 'test_posts'; + + protected array $fillable = ['id', 'user_id']; + + protected static function newFactory(): HasManyInversePostModelFactory + { + return new HasManyInversePostModelFactory(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(HasManyInverseUserModel::class, 'user_id'); + } +} + +class HasManyInversePostModelFactory extends Factory +{ + protected ?string $model = HasManyInversePostModel::class; + + public function definition(): array + { + return [ + 'user_id' => HasManyInverseUserModel::factory(), + ]; + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentInverseRelationHasOneTest.php b/tests/Database/Laravel/DatabaseEloquentInverseRelationHasOneTest.php new file mode 100755 index 000000000..86c985202 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationHasOneTest.php @@ -0,0 +1,253 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_parent', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_child', function ($table) { + $table->increments('id'); + $table->foreignId('parent_id')->unique(); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_parent'); + $this->schema()->drop('test_child'); + + parent::tearDown(); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasOneInverseChildModel::factory(5)->create(); + $models = HasOneInverseParentModel::all(); + + foreach ($models as $parent) { + $this->assertFalse($parent->relationLoaded('child')); + $child = $parent->child; + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasOneInverseChildModel::factory(5)->create(); + + $models = HasOneInverseParentModel::with('child')->get(); + + foreach ($models as $parent) { + $child = $parent->child; + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenMaking() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->make(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenCreating() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->create(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenCreatingQuietly() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->createQuietly(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenForceCreating() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->forceCreate(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenSaving() + { + $parent = HasOneInverseParentModel::create(); + $child = HasOneInverseChildModel::make(); + + $this->assertFalse($child->relationLoaded('parent')); + $parent->child()->save($child); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenSavingQuietly() + { + $parent = HasOneInverseParentModel::create(); + $child = HasOneInverseChildModel::make(); + + $this->assertFalse($child->relationLoaded('parent')); + $parent->child()->saveQuietly($child); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenUpdating() + { + $parent = HasOneInverseParentModel::create(); + $child = HasOneInverseChildModel::factory()->create(); + + $this->assertTrue($parent->isNot($child->parent)); + + $parent->child()->save($child); + + $this->assertTrue($parent->is($child->parent)); + $this->assertSame($parent, $child->parent); + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class HasOneInverseParentModel extends Model +{ + use HasFactory; + + protected ?string $table = 'test_parent'; + + protected array $fillable = ['id']; + + protected static function newFactory(): HasOneInverseParentModelFactory + { + return new HasOneInverseParentModelFactory(); + } + + public function child(): HasOne + { + return $this->hasOne(HasOneInverseChildModel::class, 'parent_id')->inverse('parent'); + } +} + +class HasOneInverseParentModelFactory extends Factory +{ + protected ?string $model = HasOneInverseParentModel::class; + + public function definition(): array + { + return []; + } +} + +class HasOneInverseChildModel extends Model +{ + use HasFactory; + + protected ?string $table = 'test_child'; + + protected array $fillable = ['id', 'parent_id']; + + protected static function newFactory(): HasOneInverseChildModelFactory + { + return new HasOneInverseChildModelFactory(); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(HasOneInverseParentModel::class, 'parent_id'); + } +} + +class HasOneInverseChildModelFactory extends Factory +{ + protected ?string $model = HasOneInverseChildModel::class; + + public function definition(): array + { + return [ + 'parent_id' => HasOneInverseParentModel::factory(), + ]; + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentInverseRelationMorphManyTest.php b/tests/Database/Laravel/DatabaseEloquentInverseRelationMorphManyTest.php new file mode 100755 index 000000000..758e561d8 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationMorphManyTest.php @@ -0,0 +1,384 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_posts', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_comments', function ($table) { + $table->increments('id'); + $table->morphs('commentable'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_posts'); + $this->schema()->drop('test_comments'); + + parent::tearDown(); + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('comments')); + $comments = $post->comments; + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::with('comments')->get(); + + foreach ($posts as $post) { + $comments = $post->getRelation('comments'); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphManyGuessedInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('guessedComments')); + $comments = $post->guessedComments; + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphManyGuessedInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::with('guessedComments')->get(); + + foreach ($posts as $post) { + $comments = $post->getRelation('guessedComments'); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphLatestOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('lastComment')); + $comment = $post->lastComment; + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphLatestOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::with('lastComment')->get(); + + foreach ($posts as $post) { + $comment = $post->getRelation('lastComment'); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphLatestOfManyGuessedInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('guessedLastComment')); + $comment = $post->guessedLastComment; + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphLatestOfManyGuessedInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::with('guessedLastComment')->get(); + + foreach ($posts as $post) { + $comment = $post->getRelation('guessedLastComment'); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphOneOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('firstComment')); + $comment = $post->firstComment; + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphOneOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::with('firstComment')->get(); + + foreach ($posts as $post) { + $comment = $post->getRelation('firstComment'); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenMakingMany() + { + $post = MorphManyInversePostModel::create(); + + $comments = $post->comments()->makeMany(array_fill(0, 3, [])); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenCreatingMany() + { + $post = MorphManyInversePostModel::create(); + + $comments = $post->comments()->createMany(array_fill(0, 3, [])); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenCreatingManyQuietly() + { + $post = MorphManyInversePostModel::create(); + + $comments = $post->comments()->createManyQuietly(array_fill(0, 3, [])); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenSavingMany() + { + $post = MorphManyInversePostModel::create(); + $comments = array_fill(0, 3, new MorphManyInverseCommentModel); + + $post->comments()->saveMany($comments); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenUpdatingMany() + { + $post = MorphManyInversePostModel::create(); + $comments = MorphManyInverseCommentModel::factory()->count(3)->create(); + + foreach ($comments as $comment) { + $this->assertTrue($post->isNot($comment->commentable)); + } + + $post->comments()->saveMany($comments); + + foreach ($comments as $comment) { + $this->assertSame($post, $comment->commentable); + } + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class MorphManyInversePostModel extends Model +{ + use HasFactory; + + protected ?string $table = 'test_posts'; + + protected array $fillable = ['id']; + + protected static function newFactory(): MorphManyInversePostModelFactory + { + return new MorphManyInversePostModelFactory(); + } + + public function comments(): MorphMany + { + return $this->morphMany(MorphManyInverseCommentModel::class, 'commentable')->inverse('commentable'); + } + + public function guessedComments(): MorphMany + { + return $this->morphMany(MorphManyInverseCommentModel::class, 'commentable')->inverse(); + } + + public function lastComment(): MorphOne + { + return $this->morphOne(MorphManyInverseCommentModel::class, 'commentable')->latestOfMany()->inverse('commentable'); + } + + public function guessedLastComment(): MorphOne + { + return $this->morphOne(MorphManyInverseCommentModel::class, 'commentable')->latestOfMany()->inverse(); + } + + public function firstComment(): MorphOne + { + return $this->comments()->one(); + } +} + +class MorphManyInversePostModelFactory extends Factory +{ + protected ?string $model = MorphManyInversePostModel::class; + + public function definition(): array + { + return []; + } + + public function withComments(int $count = 3): static + { + return $this->afterCreating(function (MorphManyInversePostModel $model) use ($count) { + MorphManyInverseCommentModel::factory()->recycle($model)->count($count)->create(); + }); + } +} + +class MorphManyInverseCommentModel extends Model +{ + use HasFactory; + + protected ?string $table = 'test_comments'; + + protected array $fillable = ['id', 'commentable_type', 'commentable_id']; + + protected static function newFactory(): MorphManyInverseCommentModelFactory + { + return new MorphManyInverseCommentModelFactory(); + } + + public function commentable(): MorphTo + { + return $this->morphTo('commentable'); + } +} + +class MorphManyInverseCommentModelFactory extends Factory +{ + protected ?string $model = MorphManyInverseCommentModel::class; + + public function definition(): array + { + return [ + 'commentable_type' => MorphManyInversePostModel::class, + 'commentable_id' => MorphManyInversePostModel::factory(), + ]; + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentInverseRelationMorphOneTest.php b/tests/Database/Laravel/DatabaseEloquentInverseRelationMorphOneTest.php new file mode 100755 index 000000000..2570c1da4 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationMorphOneTest.php @@ -0,0 +1,284 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_posts', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_images', function ($table) { + $table->increments('id'); + $table->morphs('imageable'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_posts'); + $this->schema()->drop('test_images'); + + parent::tearDown(); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('image')); + $image = $post->image; + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::with('image')->get(); + + foreach ($posts as $post) { + $image = $post->getRelation('image'); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneGuessedInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('guessedImage')); + $image = $post->guessedImage; + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneGuessedInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::with('guessedImage')->get(); + + foreach ($posts as $post) { + $image = $post->getRelation('guessedImage'); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenMaking() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->make(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenCreating() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->create(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenCreatingQuietly() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->createQuietly(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenForceCreating() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->forceCreate(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenSaving() + { + $post = MorphOneInversePostModel::create(); + $image = MorphOneInverseImageModel::make(); + + $this->assertFalse($image->relationLoaded('imageable')); + $post->image()->save($image); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenSavingQuietly() + { + $post = MorphOneInversePostModel::create(); + $image = MorphOneInverseImageModel::make(); + + $this->assertFalse($image->relationLoaded('imageable')); + $post->image()->saveQuietly($image); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenUpdating() + { + $post = MorphOneInversePostModel::create(); + $image = MorphOneInverseImageModel::factory()->create(); + + $this->assertTrue($post->isNot($image->imageable)); + + $post->image()->save($image); + + $this->assertTrue($post->is($image->imageable)); + $this->assertSame($post, $image->imageable); + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class MorphOneInversePostModel extends Model +{ + use HasFactory; + + protected ?string $table = 'test_posts'; + + protected array $fillable = ['id']; + + protected static function newFactory(): MorphOneInversePostModelFactory + { + return new MorphOneInversePostModelFactory(); + } + + public function image(): MorphOne + { + return $this->morphOne(MorphOneInverseImageModel::class, 'imageable')->inverse('imageable'); + } + + public function guessedImage(): MorphOne + { + return $this->morphOne(MorphOneInverseImageModel::class, 'imageable')->inverse(); + } +} + +class MorphOneInversePostModelFactory extends Factory +{ + protected ?string $model = MorphOneInversePostModel::class; + + public function definition(): array + { + return []; + } +} + +class MorphOneInverseImageModel extends Model +{ + use HasFactory; + + protected ?string $table = 'test_images'; + + protected array $fillable = ['id', 'imageable_type', 'imageable_id']; + + protected static function newFactory(): MorphOneInverseImageModelFactory + { + return new MorphOneInverseImageModelFactory(); + } + + public function imageable(): MorphTo + { + return $this->morphTo('imageable'); + } +} + +class MorphOneInverseImageModelFactory extends Factory +{ + protected ?string $model = MorphOneInverseImageModel::class; + + public function definition(): array + { + return [ + 'imageable_type' => MorphOneInversePostModel::class, + 'imageable_id' => MorphOneInversePostModel::factory(), + ]; + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentInverseRelationTest.php b/tests/Database/Laravel/DatabaseEloquentInverseRelationTest.php new file mode 100755 index 000000000..16d6b90a6 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationTest.php @@ -0,0 +1,398 @@ +shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + new HasInverseRelationStub($builder, new HasInverseRelationParentStub()); + } + + public function testBuilderCallbackIsNotSetIfInverseRelationIsEmptyString() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + $this->expectException(RelationNotFoundException::class); + + (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse(''); + } + + public function testBuilderCallbackIsNotSetIfInverseRelationshipDoesNotExist() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + $this->expectException(RelationNotFoundException::class); + + (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse('foo'); + } + + public function testWithoutInverseMethodRemovesInverseRelation() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub())); + $this->assertNull($relation->getInverseRelationship()); + + $relation->inverse('test'); + $this->assertSame('test', $relation->getInverseRelationship()); + + $relation->withoutInverse(); + $this->assertNull($relation->getInverseRelationship()); + } + + public function testBuilderCallbackIsAppliedWhenInverseRelationIsSet() + { + $parent = new HasInverseRelationParentStub(); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use ($parent) { + $relation = (new \ReflectionFunction($callback))->getClosureThis(); + + return $relation instanceof HasInverseRelationStub && $relation->getParent() === $parent; + })->once()->andReturnSelf(); + + (new HasInverseRelationStub($builder, $parent))->inverse('test'); + } + + public function testBuilderCallbackAppliesInverseRelationToAllModelsInResult() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + + // Capture the callback so that we can manually call it. + $afterQuery = null; + $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use (&$afterQuery) { + return (bool) $afterQuery = $callback; + })->once()->andReturnSelf(); + + $parent = new HasInverseRelationParentStub(); + (new HasInverseRelationStub($builder, $parent))->inverse('test'); + + $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); + + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + $this->assertFalse($model->relationLoaded('test')); + } + + $results = $afterQuery($results); + + foreach ($results as $model) { + $this->assertNotEmpty($model->getRelations()); + $this->assertTrue($model->relationLoaded('test')); + $this->assertSame($parent, $model->test); + } + } + + public function testInverseRelationIsNotSetIfInverseRelationIsUnset() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + + // Capture the callback so that we can manually call it. + $afterQuery = null; + $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use (&$afterQuery) { + return (bool) $afterQuery = $callback; + })->once()->andReturnSelf(); + + $parent = new HasInverseRelationParentStub(); + $relation = (new HasInverseRelationStub($builder, $parent)); + $relation->inverse('test'); + + $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + } + $results = $afterQuery($results); + foreach ($results as $model) { + $this->assertNotEmpty($model->getRelations()); + $this->assertSame($parent, $model->getRelation('test')); + } + + // Reset the inverse relation + $relation->withoutInverse(); + + $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + } + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + } + } + + public function testProvidesPossibleInverseRelationBasedOnParent() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasOneInverseChildModel); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); + + $possibleRelations = ['hasInverseRelationParentStub', 'parentStub', 'owner']; + $this->assertSame($possibleRelations, array_values($relation->exposeGetPossibleInverseRelations())); + } + + public function testProvidesPossibleInverseRelationBasedOnForeignKey() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub, 'test_id')); + + $this->assertTrue(in_array('test', $relation->exposeGetPossibleInverseRelations())); + } + + public function testProvidesPossibleRecursiveRelationsIfRelatedIsTheSameClassAsParent() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); + + $this->assertTrue(in_array('parent', $relation->exposeGetPossibleInverseRelations())); + } + + #[DataProvider('guessedParentRelationsDataProvider')] + public function testGuessesInverseRelationBasedOnParent($guessedRelation) + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === $guessedRelation); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); + + $this->assertSame($guessedRelation, $relation->exposeGuessInverseRelation()); + } + + public function testGuessesPossibleInverseRelationBasedOnForeignKey() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'test'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub, 'test_id')); + + $this->assertSame('test', $relation->exposeGuessInverseRelation()); + } + + public function testGuessesRecursiveInverseRelationsIfRelatedIsSameClassAsParent() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'parent'); + + $parent = clone $related; + $parent->shouldReceive('getForeignKey')->andReturn('recursive_parent_id'); + $parent->shouldReceive('getKeyName')->andReturn('id'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = (new HasInverseRelationStub($builder, $parent)); + + $this->assertSame('parent', $relation->exposeGuessInverseRelation()); + } + + #[DataProvider('guessedParentRelationsDataProvider')] + public function testSetsGuessedInverseRelationBasedOnParent($guessedRelation) + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === $guessedRelation); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub))->inverse(); + + $this->assertSame($guessedRelation, $relation->getInverseRelationship()); + } + + public function testSetsRecursiveInverseRelationsIfRelatedIsSameClassAsParent() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'parent'); + + $parent = clone $related; + $parent->shouldReceive('getForeignKey')->andReturn('recursive_parent_id'); + $parent->shouldReceive('getKeyName')->andReturn('id'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, $parent))->inverse(); + + $this->assertSame('parent', $relation->getInverseRelationship()); + } + + public function testSetsGuessedInverseRelationBasedOnForeignKey() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'test'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub, 'test_id'))->inverse(); + + $this->assertSame('test', $relation->getInverseRelationship()); + } + + public function testOnlyHydratesInverseRelationOnModels() + { + $relation = m::mock(HasInverseRelationStub::class)->shouldAllowMockingProtectedMethods()->makePartial(); + $relation->shouldReceive('getParent')->andReturn(new HasInverseRelationParentStub); + $relation->shouldReceive('applyInverseRelationToModel')->times(6); + $relation->exposeApplyInverseRelationToCollection([ + new HasInverseRelationRelatedStub(), + 12345, + new HasInverseRelationRelatedStub(), + new HasInverseRelationRelatedStub(), + Model::class, + new HasInverseRelationRelatedStub(), + true, + [], + new HasInverseRelationRelatedStub(), + 'foo', + new class() { + }, + new HasInverseRelationRelatedStub(), + ]); + } + + public static function guessedParentRelationsDataProvider() + { + yield ['hasInverseRelationParentStub']; + yield ['parentStub']; + yield ['owner']; + } +} + +class HasInverseRelationParentStub extends Model +{ + protected static bool $unguarded = true; + + protected string $primaryKey = 'id'; + + public function getForeignKey(): string + { + return 'parent_stub_id'; + } +} + +class HasInverseRelationRelatedStub extends Model +{ + protected static bool $unguarded = true; + + protected string $primaryKey = 'id'; + + public function getForeignKey(): string + { + return 'child_stub_id'; + } + + public function test(): BelongsTo + { + return $this->belongsTo(HasInverseRelationParentStub::class); + } +} + +class HasInverseRelationStub extends Relation +{ + use SupportsInverseRelations; + + public function __construct( + Builder $query, + Model $parent, + protected ?string $foreignKey = null, + ) { + parent::__construct($query, $parent); + $this->foreignKey ??= (new Stringable(class_basename($parent)))->snake()->finish('_id')->toString(); + } + + public function getForeignKeyName(): ?string + { + return $this->foreignKey; + } + + // None of these methods will actually be called - they're just needed to fill out `Relation` + public function match(array $models, Collection $results, $relation): array + { + return $models; + } + + public function initRelation(array $models, $relation): array + { + return $models; + } + + public function getResults(): mixed + { + return $this->query->get(); + } + + public function addConstraints(): void + { + // + } + + public function addEagerConstraints(array $models): void + { + // + } + + // Expose access to protected methods for testing + public function exposeGetPossibleInverseRelations(): array + { + return $this->getPossibleInverseRelations(); + } + + public function exposeGuessInverseRelation(): ?string + { + return $this->guessInverseRelation(); + } + + public function exposeApplyInverseRelationToCollection($models, ?Model $parent = null) + { + return $this->applyInverseRelationToCollection($models, $parent); + } +} + +/** + * Local stub for HasOneInverseChildModel (originally from DatabaseEloquentInverseRelationHasOneTest). + */ +class HasOneInverseChildModel extends Model +{ + protected ?string $table = 'test_child'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentIrregularPluralTest.php b/tests/Database/Laravel/DatabaseEloquentIrregularPluralTest.php new file mode 100644 index 000000000..ef27b6485 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentIrregularPluralTest.php @@ -0,0 +1,162 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + $this->createSchema(); + } + + public function createSchema() + { + $this->schema()->create('irregular_plural_humans', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->timestamps(); + }); + + $this->schema()->create('irregular_plural_tokens', function ($table) { + $table->increments('id'); + $table->string('title'); + }); + + $this->schema()->create('irregular_plural_human_irregular_plural_token', function ($table) { + $table->integer('irregular_plural_human_id')->unsigned(); + $table->integer('irregular_plural_token_id')->unsigned(); + }); + + $this->schema()->create('irregular_plural_mottoes', function ($table) { + $table->increments('id'); + $table->string('name'); + }); + + $this->schema()->create('cool_mottoes', function ($table) { + $table->integer('irregular_plural_motto_id'); + $table->integer('cool_motto_id'); + $table->string('cool_motto_type'); + }); + } + + protected function tearDown(): void + { + $this->schema()->drop('irregular_plural_tokens'); + $this->schema()->drop('irregular_plural_humans'); + $this->schema()->drop('irregular_plural_human_irregular_plural_token'); + + Carbon::setTestNow(null); + + parent::tearDown(); + } + + protected function schema() + { + $connection = Model::getConnectionResolver()->connection(); + + return $connection->getSchemaBuilder(); + } + + public function testItPluralizesTheTableName() + { + $model = new IrregularPluralHuman; + + $this->assertSame('irregular_plural_humans', $model->getTable()); + } + + public function testItTouchesTheParentWithAnIrregularPlural() + { + Carbon::setTestNow('2018-05-01 12:13:14'); + + IrregularPluralHuman::create(['email' => 'taylorotwell@gmail.com']); + + IrregularPluralToken::insert([ + ['title' => 'The title'], + ]); + + $human = IrregularPluralHuman::query()->first(); + + $tokenIds = IrregularPluralToken::pluck('id'); + + Carbon::setTestNow('2018-05-01 15:16:17'); + + $human->irregularPluralTokens()->sync($tokenIds); + + $human->refresh(); + + $this->assertSame('2018-05-01 12:13:14', (string) $human->created_at); + $this->assertSame('2018-05-01 15:16:17', (string) $human->updated_at); + } + + public function testItPluralizesMorphToManyRelationships() + { + $human = IrregularPluralHuman::create(['email' => 'bobby@example.com']); + + $human->mottoes()->create(['name' => 'Real eyes realize real lies']); + + $motto = IrregularPluralMotto::query()->first(); + + $this->assertSame('Real eyes realize real lies', $motto->name); + } +} + +class IrregularPluralHuman extends Model +{ + protected array $guarded = []; + + public function irregularPluralTokens() + { + return $this->belongsToMany( + IrregularPluralToken::class, + 'irregular_plural_human_irregular_plural_token', + 'irregular_plural_token_id', + 'irregular_plural_human_id' + ); + } + + public function mottoes() + { + return $this->morphToMany(IrregularPluralMotto::class, 'cool_motto'); + } +} + +class IrregularPluralToken extends Model +{ + protected array $guarded = []; + + public bool $timestamps = false; + + protected array $touches = [ + 'irregularPluralHumans', + ]; +} + +class IrregularPluralMotto extends Model +{ + protected array $guarded = []; + + public bool $timestamps = false; + + public function irregularPluralHumans() + { + return $this->morphedByMany(IrregularPluralHuman::class, 'cool_motto'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentLocalScopesTest.php b/tests/Database/Laravel/DatabaseEloquentLocalScopesTest.php new file mode 100644 index 000000000..bf20603d9 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentLocalScopesTest.php @@ -0,0 +1,107 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ])->bootEloquent(); + } + + protected function tearDown(): void + { + Model::unsetConnectionResolver(); + + parent::tearDown(); + } + + public function testCanCheckExistenceOfLocalScope() + { + $model = new EloquentLocalScopesTestModel; + + $this->assertTrue($model->hasNamedScope('active')); + $this->assertTrue($model->hasNamedScope('type')); + + $this->assertFalse($model->hasNamedScope('nonExistentLocalScope')); + } + + public function testLocalScopeIsApplied() + { + $model = new EloquentLocalScopesTestModel; + $query = $model->newQuery()->active(); + + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([true], $query->getBindings()); + } + + public function testDynamicLocalScopeIsApplied() + { + $model = new EloquentLocalScopesTestModel; + $query = $model->newQuery()->type('foo'); + + $this->assertSame('select * from "table" where "type" = ?', $query->toSql()); + $this->assertEquals(['foo'], $query->getBindings()); + } + + public function testLocalScopesCanChained() + { + $model = new EloquentLocalScopesTestModel; + $query = $model->newQuery()->active()->type('foo'); + + $this->assertSame('select * from "table" where "active" = ? and "type" = ?', $query->toSql()); + $this->assertEquals([true, 'foo'], $query->getBindings()); + } + + public function testLocalScopeNestingDoesntDoubleFirstWhereClauseNegation() + { + $model = new EloquentLocalScopesTestModel; + $query = $model + ->newQuery() + ->whereNot('firstWhere', true) + ->orWhere('secondWhere', true) + ->active(); + + $this->assertSame('select * from "table" where (not "firstWhere" = ? or "secondWhere" = ?) and "active" = ?', $query->toSql()); + $this->assertEquals([true, true, true], $query->getBindings()); + } + + public function testLocalScopeNestingGroupsOrNotWhereClause() + { + $model = new EloquentLocalScopesTestModel; + $query = $model + ->newQuery() + ->where('firstWhere', true) + ->orWhereNot('secondWhere', true) + ->active(); + + $this->assertSame('select * from "table" where ("firstWhere" = ? or not "secondWhere" = ?) and "active" = ?', $query->toSql()); + $this->assertEquals([true, true, true], $query->getBindings()); + } +} + +class EloquentLocalScopesTestModel extends Model +{ + protected ?string $table = 'table'; + + public function scopeActive($query) + { + $query->where('active', true); + } + + public function scopeType($query, $type) + { + $query->where('type', $type); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentModelTest.php b/tests/Database/Laravel/DatabaseEloquentModelTest.php new file mode 100755 index 000000000..6f93a8c2d --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentModelTest.php @@ -0,0 +1,4469 @@ +name = 'foo'; + $this->assertSame('foo', $model->name); + $this->assertTrue(isset($model->name)); + unset($model->name); + $this->assertFalse(isset($model->name)); + + // test mutation + $model->list_items = ['name' => 'taylor']; + $this->assertEquals(['name' => 'taylor'], $model->list_items); + $attributes = $model->getAttributes(); + $this->assertSame(json_encode(['name' => 'taylor']), $attributes['list_items']); + } + + public function testSetAttributeWithNumericKey() + { + $model = new EloquentDateModelStub; + $model->setAttribute(0, 'value'); + + $this->assertEquals([0 => 'value'], $model->getAttributes()); + } + + public function testDirtyAttributes() + { + $model = new EloquentModelStub(['foo' => '1', 'bar' => 2, 'baz' => 3]); + $model->syncOriginal(); + $model->foo = 1; + $model->bar = 20; + $model->baz = 30; + + $this->assertTrue($model->isDirty()); + $this->assertFalse($model->isDirty('foo')); + $this->assertTrue($model->isDirty('bar')); + $this->assertTrue($model->isDirty('foo', 'bar')); + $this->assertTrue($model->isDirty(['foo', 'bar'])); + } + + public function testIntAndNullComparisonWhenDirty() + { + $model = new EloquentModelCastingStub; + $model->intAttribute = null; + $model->syncOriginal(); + $this->assertFalse($model->isDirty('intAttribute')); + $model->forceFill(['intAttribute' => 0]); + $this->assertTrue($model->isDirty('intAttribute')); + } + + public function testFloatAndNullComparisonWhenDirty() + { + $model = new EloquentModelCastingStub; + $model->floatAttribute = null; + $model->syncOriginal(); + $this->assertFalse($model->isDirty('floatAttribute')); + $model->forceFill(['floatAttribute' => 0.0]); + $this->assertTrue($model->isDirty('floatAttribute')); + } + + public function testDirtyOnCastOrDateAttributes() + { + $model = new EloquentModelCastingStub; + $model->setDateFormat('Y-m-d H:i:s'); + $model->boolAttribute = 1; + $model->foo = 1; + $model->bar = '2017-03-18'; + $model->dateAttribute = '2017-03-18'; + $model->datetimeAttribute = '2017-03-23 22:17:00'; + $model->syncOriginal(); + + $model->boolAttribute = true; + $model->foo = true; + $model->bar = '2017-03-18 00:00:00'; + $model->dateAttribute = '2017-03-18 00:00:00'; + $model->datetimeAttribute = null; + + $this->assertTrue($model->isDirty()); + $this->assertTrue($model->isDirty('foo')); + $this->assertTrue($model->isDirty('bar')); + $this->assertFalse($model->isDirty('boolAttribute')); + $this->assertFalse($model->isDirty('dateAttribute')); + $this->assertTrue($model->isDirty('datetimeAttribute')); + } + + public function testDirtyOnCastedObjects() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'objectAttribute' => '["one", "two", "three"]', + 'collectionAttribute' => '["one", "two", "three"]', + ]); + $model->syncOriginal(); + + $model->objectAttribute = ['one', 'two', 'three']; + $model->collectionAttribute = ['one', 'two', 'three']; + + $this->assertFalse($model->isDirty()); + $this->assertFalse($model->isDirty('objectAttribute')); + $this->assertFalse($model->isDirty('collectionAttribute')); + } + + public function testDirtyOnCastedArrayObject() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asarrayobjectAttribute' => '{"foo": "bar"}', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(ArrayObject::class, $model->asarrayobjectAttribute); + $this->assertFalse($model->isDirty('asarrayobjectAttribute')); + + $model->asarrayobjectAttribute = ['foo' => 'bar']; + $this->assertFalse($model->isDirty('asarrayobjectAttribute')); + + $model->asarrayobjectAttribute = ['foo' => 'baz']; + $this->assertTrue($model->isDirty('asarrayobjectAttribute')); + } + + public function testDirtyOnCastedCollection() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'ascollectionAttribute' => '{"foo": "bar"}', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(BaseCollection::class, $model->ascollectionAttribute); + $this->assertFalse($model->isDirty('ascollectionAttribute')); + + $model->ascollectionAttribute = ['foo' => 'bar']; + $this->assertFalse($model->isDirty('ascollectionAttribute')); + + $model->ascollectionAttribute = ['foo' => 'baz']; + $this->assertTrue($model->isDirty('ascollectionAttribute')); + } + + public function testDirtyOnCastedCustomCollection() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asCustomCollectionAttribute' => '{"bar": "foo"}', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(CustomCollection::class, $model->asCustomCollectionAttribute); + $this->assertFalse($model->isDirty('asCustomCollectionAttribute')); + + $model->asCustomCollectionAttribute = ['bar' => 'foo']; + $this->assertFalse($model->isDirty('asCustomCollectionAttribute')); + + $model->asCustomCollectionAttribute = ['baz' => 'foo']; + $this->assertTrue($model->isDirty('asCustomCollectionAttribute')); + } + + public function testDirtyOnCastedCustomCollectionAsArray() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asCustomCollectionAsArrayAttribute' => '{"bar": "foo"}', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(CustomCollection::class, $model->asCustomCollectionAsArrayAttribute); + $this->assertFalse($model->isDirty('asCustomCollectionAsArrayAttribute')); + + $model->asCustomCollectionAsArrayAttribute = ['bar' => 'foo']; + $this->assertFalse($model->isDirty('asCustomCollectionAsArrayAttribute')); + + $model->asCustomCollectionAsArrayAttribute = ['baz' => 'foo']; + $this->assertTrue($model->isDirty('asCustomCollectionAsArrayAttribute')); + } + + public function testDirtyOnCastedStringable() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asStringableAttribute' => 'foo bar', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(Stringable::class, $model->asStringableAttribute); + $this->assertFalse($model->isDirty('asStringableAttribute')); + + $model->asStringableAttribute = new Stringable('foo bar'); + $this->assertFalse($model->isDirty('asStringableAttribute')); + + $model->asStringableAttribute = new Stringable('foo baz'); + $this->assertTrue($model->isDirty('asStringableAttribute')); + } + + public function testDirtyOnCastedHtmlString() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asHtmlStringAttribute' => '
foo bar
', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(HtmlString::class, $model->asHtmlStringAttribute); + $this->assertFalse($model->isDirty('asHtmlStringAttribute')); + + $model->asHtmlStringAttribute = new HtmlString('
foo bar
'); + $this->assertFalse($model->isDirty('asHtmlStringAttribute')); + + $model->asHtmlStringAttribute = new Stringable('
foo baz
'); + $this->assertTrue($model->isDirty('asHtmlStringAttribute')); + } + + public function testDirtyOnCastedUri() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asUriAttribute' => 'https://www.example.com:1234?query=param&another=value', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(Uri::class, $model->asUriAttribute); + $this->assertFalse($model->isDirty('asUriAttribute')); + + $model->asUriAttribute = new Uri('https://www.example.com:1234?query=param&another=value'); + $this->assertFalse($model->isDirty('asUriAttribute')); + + $model->asUriAttribute = new Uri('https://www.updated.com:1234?query=param&another=value'); + $this->assertTrue($model->isDirty('asUriAttribute')); + } + + public function testDirtyOnCastedFluent() + { + $value = [ + 'address' => [ + 'street' => 'test_street', + 'city' => 'test_city', + ], + ]; + + $model = new EloquentModelCastingStub; + $model->setRawAttributes(['asFluentAttribute' => json_encode($value)]); + $model->syncOriginal(); + + $this->assertInstanceOf(Fluent::class, $model->asFluentAttribute); + $this->assertFalse($model->isDirty('asFluentAttribute')); + + $model->asFluentAttribute = new Fluent($value); + $this->assertFalse($model->isDirty('asFluentAttribute')); + + $value['address']['street'] = 'updated_street'; + $model->asFluentAttribute = new Fluent($value); + $this->assertTrue($model->isDirty('asFluentAttribute')); + } + + // public function testDirtyOnCastedEncryptedCollection() + // { + // $this->encrypter = m::mock(Encrypter::class); + // Crypt::swap($this->encrypter); + // Model::$encrypter = null; + + // $this->encrypter->expects('encryptString') + // ->with('{"foo":"bar"}') + // ->andReturn('encrypted-value'); + + // $this->encrypter->expects('decryptString') + // ->with('encrypted-value') + // ->andReturn('{"foo": "bar"}'); + + // $this->encrypter->expects('encryptString') + // ->with('{"foo":"baz"}') + // ->andReturn('new-encrypted-value'); + + // $this->encrypter->expects('decrypt') + // ->with('encrypted-value', false) + // ->andReturn('{"foo": "bar"}'); + + // $this->encrypter->expects('decrypt') + // ->with('new-encrypted-value', false) + // ->andReturn('{"foo":"baz"}'); + + // $model = new EloquentModelCastingStub; + // $model->setRawAttributes([ + // 'asEncryptedCollectionAttribute' => 'encrypted-value', + // ]); + // $model->syncOriginal(); + + // $this->assertInstanceOf(BaseCollection::class, $model->asEncryptedCollectionAttribute); + // $this->assertFalse($model->isDirty('asEncryptedCollectionAttribute')); + + // $model->asEncryptedCollectionAttribute = ['foo' => 'bar']; + // $this->assertFalse($model->isDirty('asEncryptedCollectionAttribute')); + + // $model->asEncryptedCollectionAttribute = ['foo' => 'baz']; + // $this->assertTrue($model->isDirty('asEncryptedCollectionAttribute')); + // } + + // public function testDirtyOnCastedEncryptedCustomCollection() + // { + // $this->encrypter = m::mock(Encrypter::class); + // Crypt::swap($this->encrypter); + // Model::$encrypter = null; + + // $this->encrypter->expects('encryptString') + // ->twice() + // ->with('{"foo":"bar"}') + // ->andReturn('encrypted-custom-value'); + + // $this->encrypter->expects('decryptString') + // ->with('encrypted-custom-value') + // ->andReturn('{"foo": "bar"}'); + + // $this->encrypter->expects('encryptString') + // ->with('{"foo":"baz"}') + // ->andReturn('new-encrypted-custom-value'); + + // $this->encrypter->expects('decrypt') + // ->with('encrypted-custom-value', false) + // ->andReturn('{"foo": "bar"}'); + + // $this->encrypter->expects('decrypt') + // ->with('new-encrypted-custom-value', false) + // ->andReturn('{"foo":"baz"}'); + + // $model = new EloquentModelCastingStub; + // $model->setRawAttributes([ + // 'asEncryptedCustomCollectionAttribute' => 'encrypted-custom-value', + // ]); + // $model->syncOriginal(); + + // $this->assertInstanceOf(CustomCollection::class, $model->asEncryptedCustomCollectionAttribute); + // $this->assertFalse($model->isDirty('asEncryptedCustomCollectionAttribute')); + + // $model->asEncryptedCustomCollectionAttribute = ['foo' => 'bar']; + // $this->assertFalse($model->isDirty('asEncryptedCustomCollectionAttribute')); + + // $model->asEncryptedCustomCollectionAttribute = ['foo' => 'baz']; + // $this->assertTrue($model->isDirty('asEncryptedCustomCollectionAttribute')); + // } + + // public function testDirtyOnCastedEncryptedCustomCollectionAsArray() + // { + // $this->encrypter = m::mock(Encrypter::class); + // Crypt::swap($this->encrypter); + // Model::$encrypter = null; + + // $this->encrypter->expects('encryptString') + // ->twice() + // ->with('{"foo":"bar"}') + // ->andReturn('encrypted-custom-value'); + + // $this->encrypter->expects('decryptString') + // ->with('encrypted-custom-value') + // ->andReturn('{"foo": "bar"}'); + + // $this->encrypter->expects('encryptString') + // ->with('{"foo":"baz"}') + // ->andReturn('new-encrypted-custom-value'); + + // $this->encrypter->expects('decrypt') + // ->with('encrypted-custom-value', false) + // ->andReturn('{"foo": "bar"}'); + + // $this->encrypter->expects('decrypt') + // ->with('new-encrypted-custom-value', false) + // ->andReturn('{"foo":"baz"}'); + + // $model = new EloquentModelCastingStub; + // $model->setRawAttributes([ + // 'asEncryptedCustomCollectionAsArrayAttribute' => 'encrypted-custom-value', + // ]); + // $model->syncOriginal(); + + // $this->assertInstanceOf(CustomCollection::class, $model->asEncryptedCustomCollectionAsArrayAttribute); + // $this->assertFalse($model->isDirty('asEncryptedCustomCollectionAsArrayAttribute')); + + // $model->asEncryptedCustomCollectionAsArrayAttribute = ['foo' => 'bar']; + // $this->assertFalse($model->isDirty('asEncryptedCustomCollectionAsArrayAttribute')); + + // $model->asEncryptedCustomCollectionAsArrayAttribute = ['foo' => 'baz']; + // $this->assertTrue($model->isDirty('asEncryptedCustomCollectionAsArrayAttribute')); + // } + + // public function testDirtyOnCastedEncryptedArrayObject() + // { + // $this->encrypter = m::mock(Encrypter::class); + // Crypt::swap($this->encrypter); + // Model::$encrypter = null; + + // $this->encrypter->expects('encryptString') + // ->twice() + // ->with('{"foo":"bar"}') + // ->andReturn('encrypted-value'); + + // $this->encrypter->expects('decryptString') + // ->with('encrypted-value') + // ->andReturn('{"foo": "bar"}'); + + // $this->encrypter->expects('encryptString') + // ->with('{"foo":"baz"}') + // ->andReturn('new-encrypted-value'); + + // $this->encrypter->expects('decrypt') + // ->with('encrypted-value', false) + // ->andReturn('{"foo": "bar"}'); + + // $this->encrypter->expects('decrypt') + // ->with('new-encrypted-value', false) + // ->andReturn('{"foo":"baz"}'); + + // $model = new EloquentModelCastingStub; + // $model->setRawAttributes([ + // 'asEncryptedArrayObjectAttribute' => 'encrypted-value', + // ]); + // $model->syncOriginal(); + + // $this->assertInstanceOf(ArrayObject::class, $model->asEncryptedArrayObjectAttribute); + // $this->assertFalse($model->isDirty('asEncryptedArrayObjectAttribute')); + + // $model->asEncryptedArrayObjectAttribute = ['foo' => 'bar']; + // $this->assertFalse($model->isDirty('asEncryptedArrayObjectAttribute')); + + // $model->asEncryptedArrayObjectAttribute = ['foo' => 'baz']; + // $this->assertTrue($model->isDirty('asEncryptedArrayObjectAttribute')); + // } + + public function testDirtyOnEnumCollectionObject() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asEnumCollectionAttribute' => '["draft", "pending"]', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(BaseCollection::class, $model->asEnumCollectionAttribute); + $this->assertFalse($model->isDirty('asEnumCollectionAttribute')); + + $model->asEnumCollectionAttribute = ['draft', 'pending']; + $this->assertFalse($model->isDirty('asEnumCollectionAttribute')); + + $model->asEnumCollectionAttribute = ['draft', 'done']; + $this->assertTrue($model->isDirty('asEnumCollectionAttribute')); + } + + public function testDirtyOnCustomEnumCollectionObject() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asCustomEnumCollectionAttribute' => '["draft", "pending"]', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(BaseCollection::class, $model->asCustomEnumCollectionAttribute); + $this->assertFalse($model->isDirty('asCustomEnumCollectionAttribute')); + + $model->asCustomEnumCollectionAttribute = ['draft', 'pending']; + $this->assertFalse($model->isDirty('asCustomEnumCollectionAttribute')); + + $model->asCustomEnumCollectionAttribute = ['draft', 'done']; + $this->assertTrue($model->isDirty('asCustomEnumCollectionAttribute')); + } + + public function testDirtyOnEnumArrayObject() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asEnumArrayObjectAttribute' => '["draft", "pending"]', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(ArrayObject::class, $model->asEnumArrayObjectAttribute); + $this->assertFalse($model->isDirty('asEnumArrayObjectAttribute')); + + $model->asEnumArrayObjectAttribute = ['draft', 'pending']; + $this->assertFalse($model->isDirty('asEnumArrayObjectAttribute')); + + $model->asEnumArrayObjectAttribute = ['draft', 'done']; + $this->assertTrue($model->isDirty('asEnumArrayObjectAttribute')); + } + + public function testDirtyOnCustomEnumArrayObjectUsing() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'asCustomEnumArrayObjectAttribute' => '["draft", "pending"]', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(ArrayObject::class, $model->asCustomEnumArrayObjectAttribute); + $this->assertFalse($model->isDirty('asCustomEnumArrayObjectAttribute')); + + $model->asCustomEnumArrayObjectAttribute = ['draft', 'pending']; + $this->assertFalse($model->isDirty('asCustomEnumArrayObjectAttribute')); + + $model->asCustomEnumArrayObjectAttribute = ['draft', 'done']; + $this->assertTrue($model->isDirty('asCustomEnumArrayObjectAttribute')); + } + + public function testHasCastsOnEnumAttribute() + { + $model = new EloquentModelEnumCastingStub(); + $this->assertTrue($model->hasCast('enumAttribute', StringStatus::class)); + } + + public function testCleanAttributes() + { + $model = new EloquentModelStub(['foo' => '1', 'bar' => 2, 'baz' => 3]); + $model->syncOriginal(); + $model->foo = 1; + $model->bar = 20; + $model->baz = 30; + + $this->assertFalse($model->isClean()); + $this->assertTrue($model->isClean('foo')); + $this->assertFalse($model->isClean('bar')); + $this->assertFalse($model->isClean('foo', 'bar')); + $this->assertFalse($model->isClean(['foo', 'bar'])); + } + + public function testCleanWhenFloatUpdateAttribute() + { + // test is equivalent + $model = new EloquentModelStub(['castedFloat' => 8 - 6.4]); + $model->syncOriginal(); + $model->castedFloat = 1.6; + $this->assertTrue($model->originalIsEquivalent('castedFloat')); + + // test is not equivalent + $model = new EloquentModelStub(['castedFloat' => 5.6]); + $model->syncOriginal(); + $model->castedFloat = 5.5; + $this->assertFalse($model->originalIsEquivalent('castedFloat')); + } + + public function testCalculatedAttributes() + { + $model = new EloquentModelStub; + $model->password = 'secret'; + $attributes = $model->getAttributes(); + + // ensure password attribute was not set to null + $this->assertArrayNotHasKey('password', $attributes); + $this->assertSame('******', $model->password); + + $hash = 'e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4'; + + $this->assertEquals($hash, $attributes['password_hash']); + $this->assertEquals($hash, $model->password_hash); + } + + public function testArrayAccessToAttributes() + { + $model = new EloquentModelStub(['attributes' => 1, 'connection' => 2, 'table' => 3]); + unset($model['table']); + + $this->assertTrue(isset($model['attributes'])); + $this->assertEquals(1, $model['attributes']); + $this->assertTrue(isset($model['connection'])); + $this->assertEquals(2, $model['connection']); + $this->assertFalse(isset($model['table'])); + $this->assertEquals(null, $model['table']); + $this->assertFalse(isset($model['with'])); + } + + public function testOnly() + { + $model = new EloquentModelStub; + $model->first_name = 'taylor'; + $model->last_name = 'otwell'; + $model->project = 'laravel'; + + $this->assertEquals(['project' => 'laravel'], $model->only('project')); + $this->assertEquals(['first_name' => 'taylor', 'last_name' => 'otwell'], $model->only('first_name', 'last_name')); + $this->assertEquals(['first_name' => 'taylor', 'last_name' => 'otwell'], $model->only(['first_name', 'last_name'])); + } + + public function testExcept() + { + $model = new EloquentModelStub; + $model->first_name = 'taylor'; + $model->last_name = 'otwell'; + $model->project = 'laravel'; + + $this->assertEquals(['first_name' => 'taylor', 'last_name' => 'otwell'], $model->except('project')); + $this->assertEquals(['project' => 'laravel'], $model->except('first_name', 'last_name')); + $this->assertEquals(['project' => 'laravel'], $model->except(['first_name', 'last_name'])); + } + + public function testNewInstanceReturnsNewInstanceWithAttributesSet() + { + $model = new EloquentModelStub; + $instance = $model->newInstance(['name' => 'taylor']); + $this->assertInstanceOf(EloquentModelStub::class, $instance); + $this->assertSame('taylor', $instance->name); + } + + public function testNewInstanceReturnsNewInstanceWithTableSet() + { + $model = new EloquentModelStub; + $model->setTable('test'); + $newInstance = $model->newInstance(); + + $this->assertSame('test', $newInstance->getTable()); + } + + public function testNewInstanceReturnsNewInstanceWithMergedCasts() + { + $model = new EloquentModelStub; + $model->mergeCasts(['foo' => 'date']); + $newInstance = $model->newInstance(); + + $this->assertArrayHasKey('foo', $newInstance->getCasts()); + $this->assertSame('date', $newInstance->getCasts()['foo']); + } + + public function testCreateMethodSavesNewModel() + { + $_SERVER['__eloquent.saved'] = false; + $model = EloquentModelSaveStub::create(['name' => 'taylor']); + $this->assertTrue($_SERVER['__eloquent.saved']); + $this->assertSame('taylor', $model->name); + } + + public function testMakeMethodDoesNotSaveNewModel() + { + $_SERVER['__eloquent.saved'] = false; + $model = EloquentModelSaveStub::make(['name' => 'taylor']); + $this->assertFalse($_SERVER['__eloquent.saved']); + $this->assertSame('taylor', $model->name); + } + + public function testForceCreateMethodSavesNewModelWithGuardedAttributes() + { + $_SERVER['__eloquent.saved'] = false; + $model = EloquentModelSaveStub::forceCreate(['id' => 21]); + $this->assertTrue($_SERVER['__eloquent.saved']); + $this->assertEquals(21, $model->id); + } + + public function testFindMethodUseWritePdo() + { + EloquentModelFindWithWritePdoStub::onWriteConnection()->find(1); + } + + public function testDestroyMethodCallsQueryBuilderCorrectly() + { + EloquentModelDestroyStub::destroy(1, 2, 3); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithCollection() + { + EloquentModelDestroyStub::destroy(new BaseCollection([1, 2, 3])); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithEloquentCollection() + { + EloquentModelDestroyStub::destroy(new Collection([ + new EloquentModelDestroyStub(['id' => 1]), + new EloquentModelDestroyStub(['id' => 2]), + new EloquentModelDestroyStub(['id' => 3]), + ])); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithMultipleArgs() + { + EloquentModelDestroyStub::destroy(1, 2, 3); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithEmptyIds() + { + $count = EloquentModelEmptyDestroyStub::destroy([]); + $this->assertSame(0, $count); + } + + public function testWithMethodCallsQueryBuilderCorrectly() + { + $result = EloquentModelWithStub::with('foo', 'bar'); + $this->assertInstanceOf(Builder::class, $result); + } + + public function testWithoutMethodRemovesEagerLoadedRelationshipCorrectly() + { + $model = new EloquentModelWithoutRelationStub; + $this->addMockConnection($model); + $instance = $model->newInstance()->newQuery()->without('foo'); + $this->assertEmpty($instance->getEagerLoads()); + } + + public function testWithOnlyMethodLoadsRelationshipCorrectly() + { + $model = new EloquentModelWithoutRelationStub(); + $this->addMockConnection($model); + $instance = $model->newInstance()->newQuery()->withOnly('taylor'); + $this->assertNotNull($instance->getEagerLoads()['taylor']); + $this->assertArrayNotHasKey('foo', $instance->getEagerLoads()); + } + + public function testEagerLoadingWithColumns() + { + $model = new EloquentModelWithoutRelationStub; + $instance = $model->newInstance()->newQuery()->with('foo:bar,baz', 'hadi'); + $builder = m::mock(Builder::class); + $builder->shouldReceive('select')->once()->with(['bar', 'baz']); + $this->assertNotNull($instance->getEagerLoads()['hadi']); + $this->assertNotNull($instance->getEagerLoads()['foo']); + $closure = $instance->getEagerLoads()['foo']; + $closure($builder); + } + + public function testWithWhereHasWithSpecificColumns() + { + $model = new EloquentModelWithWhereHasStub; + $instance = $model->newInstance()->newQuery()->withWhereHas('foo:diaa,fares'); + $builder = m::mock(Builder::class); + $builder->shouldReceive('select')->once()->with(['diaa', 'fares']); + $this->assertNotNull($instance->getEagerLoads()['foo']); + $closure = $instance->getEagerLoads()['foo']; + $closure($builder); + } + + public function testWithWhereHasWorksInNestedQuery() + { + $model = new EloquentModelWithWhereHasStub; + $instance = $model->newInstance()->newQuery()->where(fn (Builder $q) => $q->withWhereHas('foo:diaa,fares')); + $builder = m::mock(Builder::class); + $builder->shouldReceive('select')->once()->with(['diaa', 'fares']); + $this->assertNotNull($instance->getEagerLoads()['foo']); + $closure = $instance->getEagerLoads()['foo']; + $closure($builder); + } + + public function testWithMethodCallsQueryBuilderCorrectlyWithArray() + { + $result = EloquentModelWithStub::with(['foo', 'bar']); + $this->assertInstanceOf(Builder::class, $result); + } + + public function testUpdateProcess() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('where')->once()->with('id', '=', 1); + $query->shouldReceive('update')->once()->with(['name' => 'taylor'])->andReturn(1); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.updating: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with('eloquent.updated: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with('eloquent.saved: '.get_class($model), $model)->andReturn(true); + + $model->id = 1; + $model->foo = 'bar'; + // make sure foo isn't synced so we can test that dirty attributes only are updated + $model->syncOriginal(); + $model->name = 'taylor'; + $model->exists = true; + $this->assertTrue($model->save()); + } + + public function testUpdateProcessDoesntOverrideTimestamps() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('where')->once()->with('id', '=', 1); + $query->shouldReceive('update')->once()->with(['created_at' => 'foo', 'updated_at' => 'bar'])->andReturn(1); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until'); + $events->shouldReceive('dispatch'); + + $model->id = 1; + $model->syncOriginal(); + $model->created_at = 'foo'; + $model->updated_at = 'bar'; + $model->exists = true; + $this->assertTrue($model->save()); + } + + public function testSaveIsCanceledIfSavingEventReturnsFalse() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); + $query = m::mock(Builder::class); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: '.get_class($model), $model)->andReturn(false); + $model->exists = true; + + $this->assertFalse($model->save()); + } + + public function testUpdateIsCanceledIfUpdatingEventReturnsFalse() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); + $query = m::mock(Builder::class); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.updating: '.get_class($model), $model)->andReturn(false); + $model->exists = true; + $model->foo = 'bar'; + + $this->assertFalse($model->save()); + } + + public function testEventsCanBeFiredWithCustomEventObjects() + { + $model = $this->getMockBuilder(EloquentModelEventObjectStub::class)->onlyMethods(['newModelQuery'])->getMock(); + $query = m::mock(Builder::class); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with(m::type(EloquentModelSavingEventStub::class))->andReturn(false); + $model->exists = true; + + $this->assertFalse($model->save()); + } + + public function testUpdateProcessWithoutTimestamps() + { + $model = $this->getMockBuilder(EloquentModelEventObjectStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'fireModelEvent'])->getMock(); + $model->timestamps = false; + $query = m::mock(Builder::class); + $query->shouldReceive('where')->once()->with('id', '=', 1); + $query->shouldReceive('update')->once()->with(['name' => 'taylor'])->andReturn(1); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->never())->method('updateTimestamps'); + $model->expects($this->any())->method('fireModelEvent')->willReturn(true); + + $model->id = 1; + $model->syncOriginal(); + $model->name = 'taylor'; + $model->exists = true; + $this->assertTrue($model->save()); + } + + public function testUpdateUsesOldPrimaryKey() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('where')->once()->with('id', '=', 1); + $query->shouldReceive('update')->once()->with(['id' => 2, 'foo' => 'bar'])->andReturn(1); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.updating: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with('eloquent.updated: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with('eloquent.saved: '.get_class($model), $model)->andReturn(true); + + $model->id = 1; + $model->syncOriginal(); + $model->id = 2; + $model->foo = 'bar'; + $model->exists = true; + + $this->assertTrue($model->save()); + } + + public function testTimestampsAreReturnedAsObjects() + { + $model = $this->getMockBuilder(EloquentDateModelStub::class)->onlyMethods(['getDateFormat'])->getMock(); + $model->expects($this->any())->method('getDateFormat')->willReturn('Y-m-d'); + $model->setRawAttributes([ + 'created_at' => '2012-12-04', + 'updated_at' => '2012-12-05', + ]); + + $this->assertInstanceOf(Carbon::class, $model->created_at); + $this->assertInstanceOf(Carbon::class, $model->updated_at); + } + + public function testTimestampsAreReturnedAsObjectsFromPlainDatesAndTimestamps() + { + $model = $this->getMockBuilder(EloquentDateModelStub::class)->onlyMethods(['getDateFormat'])->getMock(); + $model->expects($this->any())->method('getDateFormat')->willReturn('Y-m-d H:i:s'); + $model->setRawAttributes([ + 'created_at' => '2012-12-04', + 'updated_at' => $this->currentTime(), + ]); + + $this->assertInstanceOf(Carbon::class, $model->created_at); + $this->assertInstanceOf(Carbon::class, $model->updated_at); + } + + public function testTimestampsAreReturnedAsObjectsOnCreate() + { + $timestamps = [ + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + $model = new EloquentDateModelStub; + Model::setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $resolver->shouldReceive('connection')->andReturn($mockConnection = m::mock(Connection::class)); + $mockConnection->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $grammar->shouldReceive('getDateFormat')->andReturn('Y-m-d H:i:s'); + $instance = $model->newInstance($timestamps); + $this->assertInstanceOf(Carbon::class, $instance->updated_at); + $this->assertInstanceOf(Carbon::class, $instance->created_at); + } + + public function testDateTimeAttributesReturnNullIfSetToNull() + { + $timestamps = [ + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + $model = new EloquentDateModelStub; + Model::setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $resolver->shouldReceive('connection')->andReturn($mockConnection = m::mock(Connection::class)); + $mockConnection->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $grammar->shouldReceive('getDateFormat')->andReturn('Y-m-d H:i:s'); + $instance = $model->newInstance($timestamps); + + $instance->created_at = null; + $this->assertNull($instance->created_at); + } + + public function testTimestampsAreCreatedFromStringsAndIntegers() + { + $model = new EloquentDateModelStub; + $model->created_at = '2013-05-22 00:00:00'; + $this->assertInstanceOf(Carbon::class, $model->created_at); + + $model = new EloquentDateModelStub; + $model->created_at = $this->currentTime(); + $this->assertInstanceOf(Carbon::class, $model->created_at); + + $model = new EloquentDateModelStub; + $model->created_at = 0; + $this->assertInstanceOf(Carbon::class, $model->created_at); + + $model = new EloquentDateModelStub; + $model->created_at = '2012-01-01'; + $this->assertInstanceOf(Carbon::class, $model->created_at); + } + + public function testFromDateTime() + { + $model = new EloquentModelStub; + + $value = Carbon::parse('2015-04-17 22:59:01'); + $this->assertSame('2015-04-17 22:59:01', $model->fromDateTime($value)); + + $value = new DateTime('2015-04-17 22:59:01'); + $this->assertInstanceOf(DateTime::class, $value); + $this->assertInstanceOf(DateTimeInterface::class, $value); + $this->assertSame('2015-04-17 22:59:01', $model->fromDateTime($value)); + + $value = new DateTimeImmutable('2015-04-17 22:59:01'); + $this->assertInstanceOf(DateTimeImmutable::class, $value); + $this->assertInstanceOf(DateTimeInterface::class, $value); + $this->assertSame('2015-04-17 22:59:01', $model->fromDateTime($value)); + + $value = '2015-04-17 22:59:01'; + $this->assertSame('2015-04-17 22:59:01', $model->fromDateTime($value)); + + $value = '2015-04-17'; + $this->assertSame('2015-04-17 00:00:00', $model->fromDateTime($value)); + + $value = '2015-4-17'; + $this->assertSame('2015-04-17 00:00:00', $model->fromDateTime($value)); + + $value = '1429311541'; + $this->assertSame('2015-04-17 22:59:01', $model->fromDateTime($value)); + + $this->assertNull($model->fromDateTime(null)); + } + + public function testFromDateTimeMilliseconds() + { + $model = $this->getMockBuilder('Hypervel\Tests\Database\Laravel\EloquentDateModelStub')->onlyMethods(['getDateFormat'])->getMock(); + $model->expects($this->any())->method('getDateFormat')->willReturn('Y-m-d H:s.vi'); + $model->setRawAttributes([ + 'created_at' => '2012-12-04 22:59.32130', + ]); + + $this->assertInstanceOf(Carbon::class, $model->created_at); + $this->assertSame('22:30:59.321000', $model->created_at->format('H:i:s.u')); + } + + public function testInsertProcess() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.creating: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with('eloquent.created: '.get_class($model), $model); + $events->shouldReceive('dispatch')->once()->with('eloquent.saved: '.get_class($model), $model); + + $model->name = 'taylor'; + $model->exists = false; + $this->assertTrue($model->save()); + $this->assertEquals(1, $model->id); + $this->assertTrue($model->exists); + + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insert')->once()->with(['name' => 'taylor']); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + $model->setIncrementing(false); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.creating: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->once()->with('eloquent.created: '.get_class($model), $model); + $events->shouldReceive('dispatch')->once()->with('eloquent.saved: '.get_class($model), $model); + + $model->name = 'taylor'; + $model->exists = false; + $this->assertTrue($model->save()); + $this->assertNull($model->id); + $this->assertTrue($model->exists); + } + + public function testInsertIsCanceledIfCreatingEventReturnsFalse() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->once()->with('eloquent.saving: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->once()->with('eloquent.creating: '.get_class($model), $model)->andReturn(false); + + $this->assertFalse($model->save()); + $this->assertFalse($model->exists); + } + + public function testDeleteProperlyDeletesModel() + { + $model = $this->getMockBuilder(Model::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'touchOwners'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('where')->once()->with('id', '=', 1)->andReturn($query); + $query->shouldReceive('delete')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('touchOwners'); + $model->exists = true; + $model->id = 1; + $model->delete(); + } + + public function testPushNoRelations() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + + $model->name = 'taylor'; + $model->exists = false; + + $this->assertTrue($model->push()); + $this->assertEquals(1, $model->id); + $this->assertTrue($model->exists); + } + + public function testPushEmptyOneRelation() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + + $model->name = 'taylor'; + $model->exists = false; + $model->setRelation('relationOne', null); + + $this->assertTrue($model->push()); + $this->assertEquals(1, $model->id); + $this->assertTrue($model->exists); + $this->assertNull($model->relationOne); + } + + public function testPushOneRelation() + { + $related1 = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with(['name' => 'related1'], 'id')->andReturn(2); + $query->shouldReceive('getConnection')->once(); + $related1->expects($this->once())->method('newModelQuery')->willReturn($query); + $related1->expects($this->once())->method('updateTimestamps'); + $related1->name = 'related1'; + $related1->exists = false; + + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + + $model->name = 'taylor'; + $model->exists = false; + $model->setRelation('relationOne', $related1); + + $this->assertTrue($model->push()); + $this->assertEquals(1, $model->id); + $this->assertTrue($model->exists); + $this->assertEquals(2, $model->relationOne->id); + $this->assertTrue($model->relationOne->exists); + $this->assertEquals(2, $related1->id); + $this->assertTrue($related1->exists); + } + + public function testPushEmptyManyRelation() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + + $model->name = 'taylor'; + $model->exists = false; + $model->setRelation('relationMany', new Collection([])); + + $this->assertTrue($model->push()); + $this->assertEquals(1, $model->id); + $this->assertTrue($model->exists); + $this->assertCount(0, $model->relationMany); + } + + public function testPushManyRelation() + { + $related1 = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with(['name' => 'related1'], 'id')->andReturn(2); + $query->shouldReceive('getConnection')->once(); + $related1->expects($this->once())->method('newModelQuery')->willReturn($query); + $related1->expects($this->once())->method('updateTimestamps'); + $related1->name = 'related1'; + $related1->exists = false; + + $related2 = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with(['name' => 'related2'], 'id')->andReturn(3); + $query->shouldReceive('getConnection')->once(); + $related2->expects($this->once())->method('newModelQuery')->willReturn($query); + $related2->expects($this->once())->method('updateTimestamps'); + $related2->name = 'related2'; + $related2->exists = false; + + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with(['name' => 'taylor'], 'id')->andReturn(1); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + $model->expects($this->once())->method('updateTimestamps'); + + $model->name = 'taylor'; + $model->exists = false; + $model->setRelation('relationMany', new Collection([$related1, $related2])); + + $this->assertTrue($model->push()); + $this->assertEquals(1, $model->id); + $this->assertTrue($model->exists); + $this->assertCount(2, $model->relationMany); + $this->assertEquals([2, 3], $model->relationMany->pluck('id')->all()); + } + + public function testPushCircularRelations() + { + $parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]); + $lastId = $parent->id; + $parent->setRelation('self', $parent); + + $children = new Collection(); + for ($count = 0; $count < 2; $count++) { + $child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]); + $child->setRelation('parent', $parent); + $child->setRelation('self', $child); + $children->push($child); + } + $parent->setRelation('children', $children); + + try { + $this->assertTrue($parent->push()); + } catch (\RuntimeException $e) { + $this->fail($e->getMessage()); + } + } + + public function testNewQueryReturnsEloquentQueryBuilder() + { + $conn = m::mock(Connection::class); + $grammar = m::mock(Grammar::class); + $processor = m::mock(Processor::class); + EloquentModelStub::setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $conn->shouldReceive('query')->andReturnUsing(function () use ($conn, $grammar, $processor) { + return new BaseBuilder($conn, $grammar, $processor); + }); + $resolver->shouldReceive('connection')->andReturn($conn); + $model = new EloquentModelStub; + $builder = $model->newQuery(); + $this->assertInstanceOf(Builder::class, $builder); + } + + public function testGetAndSetTableOperations() + { + $model = new EloquentModelStub; + $this->assertSame('stub', $model->getTable()); + $model->setTable('foo'); + $this->assertSame('foo', $model->getTable()); + } + + public function testGetKeyReturnsValueOfPrimaryKey() + { + $model = new EloquentModelStub; + $model->id = 1; + $this->assertEquals(1, $model->getKey()); + $this->assertSame('id', $model->getKeyName()); + } + + public function testConnectionManagement() + { + EloquentModelStub::setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $model = m::mock(EloquentModelStub::class.'[getConnectionName,connection]'); + + $retval = $model->setConnection('foo'); + $this->assertEquals($retval, $model); + $this->assertSame('foo', $model->connection); + + $model->shouldReceive('getConnectionName')->once()->andReturn('somethingElse'); + $resolver->shouldReceive('connection')->once()->with('somethingElse')->andReturn($mockConnection = m::mock(Connection::class)); + + $this->assertSame($mockConnection, $model->getConnection()); + } + + #[TestWith(['Foo'])] + #[TestWith([ConnectionName::Foo])] + #[TestWith([ConnectionNameBacked::Foo])] + public function testConnectionEnums(string|\UnitEnum $connectionName) + { + EloquentModelStub::setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $model = new EloquentModelStub; + + $retval = $model->setConnection($connectionName); + $this->assertEquals($retval, $model); + $this->assertSame('Foo', $model->getConnectionName()); + + $resolver->shouldReceive('connection')->once()->with('Foo')->andReturn($mockConnection = m::mock(Connection::class)); + + $this->assertSame($mockConnection, $model->getConnection()); + } + + public function testToArray() + { + $model = new EloquentModelStub; + $model->name = 'foo'; + $model->age = null; + $model->password = 'password1'; + $model->setHidden(['password']); + $model->setRelation('names', new BaseCollection([ + new EloquentModelStub(['bar' => 'baz']), new EloquentModelStub(['bam' => 'boom']), + ])); + $model->setRelation('partner', new EloquentModelStub(['name' => 'abby'])); + $model->setRelation('group', null); + $model->setRelation('multi', new BaseCollection); + $array = $model->toArray(); + + $this->assertIsArray($array); + $this->assertSame('foo', $array['name']); + $this->assertSame('baz', $array['names'][0]['bar']); + $this->assertSame('boom', $array['names'][1]['bam']); + $this->assertSame('abby', $array['partner']['name']); + $this->assertNull($array['group']); + $this->assertEquals([], $array['multi']); + $this->assertFalse(isset($array['password'])); + + $model->setAppends(['appendable']); + $array = $model->toArray(); + $this->assertSame('appended', $array['appendable']); + } + + public function testToArrayWithCircularRelations() + { + $parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]); + $lastId = $parent->id; + $parent->setRelation('self', $parent); + + $children = new Collection(); + for ($count = 0; $count < 2; $count++) { + $child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]); + $child->setRelation('parent', $parent); + $child->setRelation('self', $child); + $children->push($child); + } + $parent->setRelation('children', $children); + + try { + $this->assertSame( + [ + 'id' => 1, + 'parent_id' => null, + 'self' => ['id' => 1, 'parent_id' => null], + 'children' => [ + [ + 'id' => 2, + 'parent_id' => 1, + 'parent' => ['id' => 1, 'parent_id' => null], + 'self' => ['id' => 2, 'parent_id' => 1], + ], + [ + 'id' => 3, + 'parent_id' => 1, + 'parent' => ['id' => 1, 'parent_id' => null], + 'self' => ['id' => 3, 'parent_id' => 1], + ], + ], + ], + $parent->toArray() + ); + } catch (\RuntimeException $e) { + $this->fail($e->getMessage()); + } + } + + public function testGetQueueableRelationsWithCircularRelations() + { + $parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]); + $lastId = $parent->id; + $parent->setRelation('self', $parent); + + $children = new Collection(); + for ($count = 0; $count < 2; $count++) { + $child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]); + $child->setRelation('parent', $parent); + $child->setRelation('self', $child); + $children->push($child); + } + $parent->setRelation('children', $children); + + try { + $this->assertSame( + [ + 'self', + 'children', + 'children.parent', + 'children.self', + ], + $parent->getQueueableRelations() + ); + } catch (\RuntimeException $e) { + $this->fail($e->getMessage()); + } + } + + public function testVisibleCreatesArrayWhitelist() + { + $model = new EloquentModelStub; + $model->setVisible(['name']); + $model->name = 'Taylor'; + $model->age = 26; + $array = $model->toArray(); + + $this->assertEquals(['name' => 'Taylor'], $array); + } + + public function testHiddenCanAlsoExcludeRelationships() + { + $model = new EloquentModelStub; + $model->name = 'Taylor'; + $model->setRelation('foo', ['bar']); + $model->setHidden(['foo', 'list_items', 'password']); + $array = $model->toArray(); + + $this->assertEquals(['name' => 'Taylor'], $array); + } + + public function testGetArrayableRelationsFunctionExcludeHiddenRelationships() + { + $model = new EloquentModelStub; + + $class = new ReflectionClass($model); + $method = $class->getMethod('getArrayableRelations'); + + $model->setRelation('foo', ['bar']); + $model->setRelation('bam', ['boom']); + $model->setHidden(['foo']); + + $array = $method->invokeArgs($model, []); + + $this->assertSame(['bam' => ['boom']], $array); + } + + public function testToArraySnakeAttributes() + { + $model = new EloquentModelStub; + $model->setRelation('namesList', new BaseCollection([ + new EloquentModelStub(['bar' => 'baz']), new EloquentModelStub(['bam' => 'boom']), + ])); + $array = $model->toArray(); + + $this->assertSame('baz', $array['names_list'][0]['bar']); + $this->assertSame('boom', $array['names_list'][1]['bam']); + + $model = new EloquentModelCamelStub; + $model->setRelation('namesList', new BaseCollection([ + new EloquentModelStub(['bar' => 'baz']), new EloquentModelStub(['bam' => 'boom']), + ])); + $array = $model->toArray(); + + $this->assertSame('baz', $array['namesList'][0]['bar']); + $this->assertSame('boom', $array['namesList'][1]['bam']); + } + + public function testToArrayUsesMutators() + { + $model = new EloquentModelStub; + $model->list_items = [1, 2, 3]; + $array = $model->toArray(); + + $this->assertEquals([1, 2, 3], $array['list_items']); + } + + public function testHidden() + { + $model = new EloquentModelStub(['name' => 'foo', 'age' => 'bar', 'id' => 'baz']); + $model->setHidden(['age', 'id']); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + } + + public function testMergeHiddenMergesHidden() + { + $model = new EloquentModelHiddenStub; + + $hiddenCount = count($model->getHidden()); + $this->assertContains('foo', $model->getHidden()); + + $model->mergeHidden(['bar']); + $this->assertCount($hiddenCount + 1, $model->getHidden()); + $this->assertContains('bar', $model->getHidden()); + } + + public function testVisible() + { + $model = new EloquentModelStub(['name' => 'foo', 'age' => 'bar', 'id' => 'baz']); + $model->setVisible(['name', 'id']); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + } + + public function testMergeVisibleMergesVisible() + { + $model = new EloquentModelVisibleStub; + + $visibleCount = count($model->getVisible()); + $this->assertContains('foo', $model->getVisible()); + + $model->mergeVisible(['bar']); + $this->assertCount($visibleCount + 1, $model->getVisible()); + $this->assertContains('bar', $model->getVisible()); + } + + public function testDynamicHidden() + { + $model = new EloquentModelDynamicHiddenStub(['name' => 'foo', 'age' => 'bar', 'id' => 'baz']); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + } + + public function testWithHidden() + { + $model = new EloquentModelStub(['name' => 'foo', 'age' => 'bar', 'id' => 'baz']); + $model->setHidden(['age', 'id']); + $model->makeVisible('age'); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayNotHasKey('id', $array); + } + + public function testMakeHidden() + { + $model = new EloquentModelStub(['name' => 'foo', 'age' => 'bar', 'address' => 'foobar', 'id' => 'baz']); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayHasKey('address', $array); + $this->assertArrayHasKey('id', $array); + + $array = $model->makeHidden('address')->toArray(); + $this->assertArrayNotHasKey('address', $array); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayHasKey('id', $array); + + $array = $model->makeHidden(['name', 'age'])->toArray(); + $this->assertArrayNotHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + $this->assertArrayNotHasKey('address', $array); + $this->assertArrayHasKey('id', $array); + } + + public function testDynamicVisible() + { + $model = new EloquentModelDynamicVisibleStub(['name' => 'foo', 'age' => 'bar', 'id' => 'baz']); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + } + + public function testMakeVisibleIf() + { + $model = new EloquentModelStub(['name' => 'foo', 'age' => 'bar', 'id' => 'baz']); + $model->setHidden(['age', 'id']); + $model->makeVisibleIf(true, 'age'); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayNotHasKey('id', $array); + + $model->setHidden(['age', 'id']); + $model->makeVisibleIf(false, 'age'); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + $this->assertArrayNotHasKey('id', $array); + + $model->setHidden(['age', 'id']); + $model->makeVisibleIf(function ($model) { + return ! is_null($model->name); + }, 'age'); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayNotHasKey('id', $array); + } + + public function testMakeHiddenIf() + { + $model = new EloquentModelStub(['name' => 'foo', 'age' => 'bar', 'address' => 'foobar', 'id' => 'baz']); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayHasKey('address', $array); + $this->assertArrayHasKey('id', $array); + + $array = $model->makeHiddenIf(true, 'address')->toArray(); + $this->assertArrayNotHasKey('address', $array); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayHasKey('id', $array); + + $model->makeVisible('address'); + + $array = $model->makeHiddenIf(false, ['name', 'age'])->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('age', $array); + $this->assertArrayHasKey('address', $array); + $this->assertArrayHasKey('id', $array); + + $array = $model->makeHiddenIf(function ($model) { + return ! is_null($model->id); + }, ['name', 'age'])->toArray(); + $this->assertArrayHasKey('address', $array); + $this->assertArrayNotHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + $this->assertArrayHasKey('id', $array); + } + + public function testFillable() + { + $model = new EloquentModelStub; + $model->fillable(['name', 'age']); + $model->fill(['name' => 'foo', 'age' => 'bar']); + $this->assertSame('foo', $model->name); + $this->assertSame('bar', $model->age); + } + + public function testQualifyColumn() + { + $model = new EloquentModelStub; + + $this->assertSame('stub.column', $model->qualifyColumn('column')); + } + + public function testForceFillMethodFillsGuardedAttributes() + { + $model = (new EloquentModelSaveStub)->forceFill(['id' => 21]); + $this->assertEquals(21, $model->id); + } + + public function testFillingJSONAttributes() + { + $model = new EloquentModelStub; + $model->fillable(['meta->name', 'meta->price', 'meta->size->width']); + $model->fill(['meta->name' => 'foo', 'meta->price' => 'bar', 'meta->size->width' => 'baz']); + $this->assertEquals( + ['meta' => json_encode(['name' => 'foo', 'price' => 'bar', 'size' => ['width' => 'baz']])], + $model->toArray() + ); + + $model = new EloquentModelStub(['meta' => json_encode(['name' => 'Taylor'])]); + $model->fillable(['meta->name', 'meta->price', 'meta->size->width']); + $model->fill(['meta->name' => 'foo', 'meta->price' => 'bar', 'meta->size->width' => 'baz']); + $this->assertEquals( + ['meta' => json_encode(['name' => 'foo', 'price' => 'bar', 'size' => ['width' => 'baz']])], + $model->toArray() + ); + } + + public function testUnguardAllowsAnythingToBeSet() + { + $model = new EloquentModelStub; + EloquentModelStub::unguard(); + $model->guard(['*']); + $model->fill(['name' => 'foo', 'age' => 'bar']); + $this->assertSame('foo', $model->name); + $this->assertSame('bar', $model->age); + EloquentModelStub::unguard(false); + } + + public function testUnderscorePropertiesAreNotFilled() + { + $model = new EloquentModelStub; + $model->fill(['_method' => 'PUT']); + $this->assertEquals([], $model->getAttributes()); + } + + public function testGuarded() + { + $model = new EloquentModelStub; + + EloquentModelStub::setConnectionResolver($resolver = m::mock(Resolver::class)); + $resolver->shouldReceive('connection')->andReturn($connection = m::mock(Connection::class)); + $connection->shouldReceive('getSchemaBuilder->getColumnListing')->andReturn(['name', 'age', 'foo']); + + $model->guard(['name', 'age']); + $model->fill(['name' => 'foo', 'age' => 'bar', 'foo' => 'bar']); + $this->assertFalse(isset($model->name)); + $this->assertFalse(isset($model->age)); + $this->assertSame('bar', $model->foo); + + $model = new EloquentModelStub; + $model->guard(['name', 'age']); + $model->fill(['Foo' => 'bar']); + $this->assertFalse(isset($model->Foo)); + + $handledMassAssignmentExceptions = 0; + + Model::preventSilentlyDiscardingAttributes(); + + $this->expectException(MassAssignmentException::class); + $model = new EloquentModelStub; + $model->guard(['name', 'age']); + $model->fill(['Foo' => 'bar']); + + Model::preventSilentlyDiscardingAttributes(false); + } + + public function testGuardedWithFillableConfig(): void + { + $model = new EloquentModelStub; + $model::unguard(); + + EloquentModelStub::setConnectionResolver($resolver = m::mock(Resolver::class)); + $resolver->shouldReceive('connection')->andReturn($connection = m::mock(Connection::class)); + $connection->shouldReceive('getSchemaBuilder->getColumnListing')->andReturn(['name', 'age', 'foo']); + + $model->guard([]); + $model->fillable(['name']); + $model->fill(['name' => 'Leto Atreides', 'age' => 51]); + + self::assertSame( + ['name' => 'Leto Atreides', 'age' => 51], + $model->getAttributes(), + ); + + $model::reguard(); + } + + public function testUsesOverriddenHandlerWhenDiscardingAttributes() + { + EloquentModelStub::setConnectionResolver($resolver = m::mock(Resolver::class)); + $resolver->shouldReceive('connection')->andReturn($connection = m::mock(Connection::class)); + $connection->shouldReceive('getSchemaBuilder->getColumnListing')->andReturn(['name', 'age', 'foo']); + + Model::preventSilentlyDiscardingAttributes(); + + $callbackModel = null; + $callbackKeys = null; + Model::handleDiscardedAttributeViolationUsing(function ($model, $keys) use (&$callbackModel, &$callbackKeys) { + $callbackModel = $model; + $callbackKeys = $keys; + }); + + $model = new EloquentModelStub; + $model->guard(['name', 'age']); + $model->fill(['Foo' => 'bar']); + + $this->assertInstanceOf(EloquentModelStub::class, $callbackModel); + $this->assertEquals(['Foo'], $callbackKeys); + + Model::preventSilentlyDiscardingAttributes(false); + Model::handleDiscardedAttributeViolationUsing(null); + } + + public function testFillableOverridesGuarded() + { + Model::preventSilentlyDiscardingAttributes(false); + + $model = new EloquentModelStub; + $model->guard([]); + $model->fillable(['age', 'foo']); + $model->fill(['name' => 'foo', 'age' => 'bar', 'foo' => 'bar']); + $this->assertFalse(isset($model->name)); + $this->assertSame('bar', $model->age); + $this->assertSame('bar', $model->foo); + } + + public function testGlobalGuarded() + { + $this->expectException(MassAssignmentException::class); + $this->expectExceptionMessage('name'); + + $model = new EloquentModelStub; + $model->guard(['*']); + $model->fill(['name' => 'foo', 'age' => 'bar', 'votes' => 'baz']); + } + + public function testUnguardedRunsCallbackWhileBeingUnguarded() + { + $model = Model::unguarded(function () { + return (new EloquentModelStub)->guard(['*'])->fill(['name' => 'Taylor']); + }); + $this->assertSame('Taylor', $model->name); + $this->assertFalse(Model::isUnguarded()); + } + + public function testUnguardedCallDoesNotChangeUnguardedState() + { + Model::unguard(); + $model = Model::unguarded(function () { + return (new EloquentModelStub)->guard(['*'])->fill(['name' => 'Taylor']); + }); + $this->assertSame('Taylor', $model->name); + $this->assertTrue(Model::isUnguarded()); + Model::reguard(); + } + + public function testUnguardedCallDoesNotChangeUnguardedStateOnException() + { + try { + Model::unguarded(function () { + throw new Exception; + }); + } catch (Exception) { + // ignore the exception + } + $this->assertFalse(Model::isUnguarded()); + } + + public function testHasOneCreatesProperRelation() + { + $model = new EloquentModelStub; + $this->addMockConnection($model); + $relation = $model->hasOne(EloquentModelSaveStub::class); + $this->assertSame('save_stub.eloquent_model_stub_id', $relation->getQualifiedForeignKeyName()); + + $model = new EloquentModelStub; + $this->addMockConnection($model); + $relation = $model->hasOne(EloquentModelSaveStub::class, 'foo'); + $this->assertSame('save_stub.foo', $relation->getQualifiedForeignKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(EloquentModelSaveStub::class, $relation->getQuery()->getModel()); + } + + public function testMorphOneCreatesProperRelation() + { + $model = new EloquentModelStub; + $this->addMockConnection($model); + $relation = $model->morphOne(EloquentModelSaveStub::class, 'morph'); + $this->assertSame('save_stub.morph_id', $relation->getQualifiedForeignKeyName()); + $this->assertSame('save_stub.morph_type', $relation->getQualifiedMorphType()); + $this->assertEquals(EloquentModelStub::class, $relation->getMorphClass()); + } + + public function testCorrectMorphClassIsReturned() + { + Relation::morphMap(['alias' => 'AnotherModel']); + $model = new EloquentModelStub; + + try { + $this->assertEquals(EloquentModelStub::class, $model->getMorphClass()); + } finally { + Relation::morphMap([], false); + } + } + + public function testHasManyCreatesProperRelation() + { + $model = new EloquentModelStub; + $this->addMockConnection($model); + $relation = $model->hasMany(EloquentModelSaveStub::class); + $this->assertSame('save_stub.eloquent_model_stub_id', $relation->getQualifiedForeignKeyName()); + + $model = new EloquentModelStub; + $this->addMockConnection($model); + $relation = $model->hasMany(EloquentModelSaveStub::class, 'foo'); + + $this->assertSame('save_stub.foo', $relation->getQualifiedForeignKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(EloquentModelSaveStub::class, $relation->getQuery()->getModel()); + } + + public function testMorphManyCreatesProperRelation() + { + $model = new EloquentModelStub; + $this->addMockConnection($model); + $relation = $model->morphMany(EloquentModelSaveStub::class, 'morph'); + $this->assertSame('save_stub.morph_id', $relation->getQualifiedForeignKeyName()); + $this->assertSame('save_stub.morph_type', $relation->getQualifiedMorphType()); + $this->assertEquals(EloquentModelStub::class, $relation->getMorphClass()); + } + + public function testBelongsToCreatesProperRelation() + { + $model = new EloquentModelStub; + $this->addMockConnection($model); + $relation = $model->belongsToStub(); + $this->assertSame('belongs_to_stub_id', $relation->getForeignKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(EloquentModelSaveStub::class, $relation->getQuery()->getModel()); + + $model = new EloquentModelStub; + $this->addMockConnection($model); + $relation = $model->belongsToExplicitKeyStub(); + $this->assertSame('foo', $relation->getForeignKeyName()); + } + + public function testMorphToCreatesProperRelation() + { + $model = new EloquentModelStub; + $this->addMockConnection($model); + + // $this->morphTo(); + $model->setAttribute('morph_to_stub_type', EloquentModelSaveStub::class); + $relation = $model->morphToStub(); + $this->assertSame('morph_to_stub_id', $relation->getForeignKeyName()); + $this->assertSame('morph_to_stub_type', $relation->getMorphType()); + $this->assertSame('morphToStub', $relation->getRelationName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(EloquentModelSaveStub::class, $relation->getQuery()->getModel()); + + // $this->morphTo(null, 'type', 'id'); + $relation2 = $model->morphToStubWithKeys(); + $this->assertSame('id', $relation2->getForeignKeyName()); + $this->assertSame('type', $relation2->getMorphType()); + $this->assertSame('morphToStubWithKeys', $relation2->getRelationName()); + + // $this->morphTo('someName'); + $relation3 = $model->morphToStubWithName(); + $this->assertSame('some_name_id', $relation3->getForeignKeyName()); + $this->assertSame('some_name_type', $relation3->getMorphType()); + $this->assertSame('someName', $relation3->getRelationName()); + + // $this->morphTo('someName', 'type', 'id'); + $relation4 = $model->morphToStubWithNameAndKeys(); + $this->assertSame('id', $relation4->getForeignKeyName()); + $this->assertSame('type', $relation4->getMorphType()); + $this->assertSame('someName', $relation4->getRelationName()); + } + + public function testBelongsToManyCreatesProperRelation() + { + $model = new EloquentModelStub; + $this->addMockConnection($model); + + $relation = $model->belongsToMany(EloquentModelSaveStub::class); + $this->assertSame('eloquent_model_save_stub_eloquent_model_stub.eloquent_model_stub_id', $relation->getQualifiedForeignPivotKeyName()); + $this->assertSame('eloquent_model_save_stub_eloquent_model_stub.eloquent_model_save_stub_id', $relation->getQualifiedRelatedPivotKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(EloquentModelSaveStub::class, $relation->getQuery()->getModel()); + $this->assertEquals(__FUNCTION__, $relation->getRelationName()); + + $model = new EloquentModelStub; + $this->addMockConnection($model); + $relation = $model->belongsToMany(EloquentModelSaveStub::class, 'table', 'foreign', 'other'); + $this->assertSame('table.foreign', $relation->getQualifiedForeignPivotKeyName()); + $this->assertSame('table.other', $relation->getQualifiedRelatedPivotKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(EloquentModelSaveStub::class, $relation->getQuery()->getModel()); + } + + public function testRelationsWithVariedConnections() + { + // Has one + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasOne(EloquentNoConnectionModelStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasOne(EloquentDifferentConnectionModelStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // Morph One + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->morphOne(EloquentNoConnectionModelStub::class, 'type'); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->morphOne(EloquentDifferentConnectionModelStub::class, 'type'); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // Belongs to + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->belongsTo(EloquentNoConnectionModelStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->belongsTo(EloquentDifferentConnectionModelStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // has many + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasMany(EloquentNoConnectionModelStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasMany(EloquentDifferentConnectionModelStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // has many through + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasManyThrough(EloquentNoConnectionModelStub::class, EloquentModelSaveStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasManyThrough(EloquentDifferentConnectionModelStub::class, EloquentModelSaveStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // belongs to many + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->belongsToMany(EloquentNoConnectionModelStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new EloquentModelStub; + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->belongsToMany(EloquentDifferentConnectionModelStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + } + + public function testModelsAssumeTheirName() + { + require_once __DIR__.'/stubs/EloquentModelNamespacedStub.php'; + + $model = new EloquentModelWithoutTableStub; + $this->assertSame('eloquent_model_without_table_stubs', $model->getTable()); + + $namespacedModel = new EloquentModelNamespacedStub; + $this->assertSame('eloquent_model_namespaced_stubs', $namespacedModel->getTable()); + } + + public function testTheMutatorCacheIsPopulated() + { + $class = new EloquentModelStub; + + $expectedAttributes = [ + 'list_items', + 'password', + 'appendable', + ]; + + $this->assertEquals($expectedAttributes, $class->getMutatedAttributes()); + } + + public function testRouteKeyIsPrimaryKey() + { + $model = new EloquentModelNonIncrementingStub; + $model->id = 'foo'; + $this->assertSame('foo', $model->getRouteKey()); + } + + public function testRouteNameIsPrimaryKeyName() + { + $model = new EloquentModelStub; + $this->assertSame('id', $model->getRouteKeyName()); + } + + public function testCloneModelMakesAFreshCopyOfTheModel() + { + $class = new EloquentModelStub; + $class->id = 1; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->id); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasUuidPrimaryKey() + { + $class = new EloquentPrimaryUuidModelStub(); + $class->uuid = 'ccf55569-bc4a-4450-875f-b5cffb1b34ec'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->uuid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasUuid() + { + $class = new EloquentNonPrimaryUuidModelStub(); + $class->id = 1; + $class->uuid = 'ccf55569-bc4a-4450-875f-b5cffb1b34ec'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->id); + $this->assertNull($clone->uuid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasUlidPrimaryKey() + { + $class = new EloquentPrimaryUlidModelStub(); + $class->ulid = '01HBZ975D8606P6CV672KW1AP2'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->ulid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasUlid() + { + $class = new EloquentNonPrimaryUlidModelStub(); + $class->id = 1; + $class->ulid = '01HBZ975D8606P6CV672KW1AP2'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->id); + $this->assertNull($clone->ulid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + + public function testModelObserversCanBeAttachedToModels() + { + EloquentModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestObserverStub::class.'@saved'); + $events->shouldReceive('forget'); + EloquentModelStub::observe(new EloquentTestObserverStub); + EloquentModelStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsWithString() + { + EloquentModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestObserverStub::class.'@saved'); + $events->shouldReceive('forget'); + EloquentModelStub::observe(EloquentTestObserverStub::class); + EloquentModelStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsThroughAnArray() + { + EloquentModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestObserverStub::class.'@saved'); + $events->shouldReceive('forget'); + EloquentModelStub::observe([EloquentTestObserverStub::class]); + EloquentModelStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsWithStringUsingAttribute() + { + EloquentModelWithObserveAttributeStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeStub', EloquentTestObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeStub', EloquentTestObserverStub::class.'@saved'); + $events->shouldReceive('forget'); + EloquentModelWithObserveAttributeStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsThroughAnArrayUsingAttribute() + { + EloquentModelWithObserveAttributeUsingArrayStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeUsingArrayStub', EloquentTestObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeUsingArrayStub', EloquentTestObserverStub::class.'@saved'); + $events->shouldReceive('forget'); + EloquentModelWithObserveAttributeUsingArrayStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsThroughAttributesOnParentClasses() + { + EloquentModelWithObserveAttributeGrandchildStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeGrandchildStub', EloquentTestObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeGrandchildStub', EloquentTestObserverStub::class.'@saved'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeGrandchildStub', EloquentTestAnotherObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeGrandchildStub', EloquentTestAnotherObserverStub::class.'@saved'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeGrandchildStub', EloquentTestThirdObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelWithObserveAttributeGrandchildStub', EloquentTestThirdObserverStub::class.'@saved'); + $events->shouldReceive('forget'); + EloquentModelWithObserveAttributeGrandchildStub::flushEventListeners(); + } + + public function testThrowExceptionOnAttachingNotExistsModelObserverWithString() + { + $this->expectException(InvalidArgumentException::class); + EloquentModelStub::observe(NotExistClass::class); + } + + public function testThrowExceptionOnAttachingNotExistsModelObserversThroughAnArray() + { + $this->expectException(InvalidArgumentException::class); + EloquentModelStub::observe([NotExistClass::class]); + } + + public function testModelObserversCanBeAttachedToModelsThroughCallingObserveMethodOnlyOnce() + { + EloquentModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestObserverStub::class.'@saved'); + + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestAnotherObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelStub', EloquentTestAnotherObserverStub::class.'@saved'); + + $events->shouldReceive('forget'); + + EloquentModelStub::observe([ + EloquentTestObserverStub::class, + EloquentTestAnotherObserverStub::class, + ]); + + EloquentModelStub::flushEventListeners(); + } + + public function testWithoutEventDispatcher() + { + EloquentModelSaveStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\EloquentModelSaveStub', EloquentTestObserverStub::class.'@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelSaveStub', EloquentTestObserverStub::class.'@saved'); + $events->shouldNotReceive('until'); + $events->shouldNotReceive('dispatch'); + $events->shouldReceive('forget'); + EloquentModelSaveStub::observe(EloquentTestObserverStub::class); + + $model = EloquentModelSaveStub::withoutEvents(function () { + $model = new EloquentModelSaveStub; + $model->save(); + + return $model; + }); + + $model->withoutEvents(function () use ($model) { + $model->first_name = 'Taylor'; + $model->save(); + }); + + $events->shouldReceive('until')->once()->with('eloquent.saving: Hypervel\Tests\Database\Laravel\EloquentModelSaveStub', $model); + $events->shouldReceive('dispatch')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\EloquentModelSaveStub', $model); + + $model->last_name = 'Otwell'; + $model->save(); + + EloquentModelSaveStub::flushEventListeners(); + } + + public function testSetObservableEvents() + { + $class = new EloquentModelStub; + $class->setObservableEvents(['foo']); + + $this->assertContains('foo', $class->getObservableEvents()); + } + + public function testAddObservableEvent() + { + $class = new EloquentModelStub; + $class->addObservableEvents('foo'); + + $this->assertContains('foo', $class->getObservableEvents()); + } + + public function testAddMultipleObserveableEvents() + { + $class = new EloquentModelStub; + $class->addObservableEvents('foo', 'bar'); + + $this->assertContains('foo', $class->getObservableEvents()); + $this->assertContains('bar', $class->getObservableEvents()); + } + + public function testRemoveObservableEvent() + { + $class = new EloquentModelStub; + $class->setObservableEvents(['foo', 'bar']); + $class->removeObservableEvents('bar'); + + $this->assertNotContains('bar', $class->getObservableEvents()); + } + + public function testRemoveMultipleObservableEvents() + { + $class = new EloquentModelStub; + $class->setObservableEvents(['foo', 'bar']); + $class->removeObservableEvents('foo', 'bar'); + + $this->assertNotContains('foo', $class->getObservableEvents()); + $this->assertNotContains('bar', $class->getObservableEvents()); + } + + public function testGetModelAttributeMethodThrowsExceptionIfNotRelation() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Hypervel\Tests\Database\Laravel\EloquentModelStub::incorrectRelationStub must return a relationship instance.'); + + $model = new EloquentModelStub; + $model->incorrectRelationStub; + } + + public function testModelIsBootedOnUnserialize() + { + $model = new EloquentModelBootingTestStub; + $this->assertTrue(EloquentModelBootingTestStub::isBooted()); + $model->foo = 'bar'; + $string = serialize($model); + $model = null; + EloquentModelBootingTestStub::unboot(); + $this->assertFalse(EloquentModelBootingTestStub::isBooted()); + unserialize($string); + $this->assertTrue(EloquentModelBootingTestStub::isBooted()); + } + + public function testCallbacksCanBeRunAfterBootingHasFinished() + { + $this->assertFalse(EloquentModelBootingCallbackTestStub::$bootHasFinished); + + $model = new EloquentModelBootingCallbackTestStub(); + + $this->assertTrue($model::$bootHasFinished); + + EloquentModelBootingCallbackTestStub::unboot(); + } + + public function testBootedCallbacksAreSeparatedByClass() + { + $this->assertFalse(EloquentModelBootingCallbackTestStub::$bootHasFinished); + + $model = new EloquentModelBootingCallbackTestStub(); + + $this->assertTrue($model::$bootHasFinished); + + $this->assertFalse(EloquentChildModelBootingCallbackTestStub::$bootHasFinished); + + $model = new EloquentChildModelBootingCallbackTestStub(); + + $this->assertTrue($model::$bootHasFinished); + + EloquentModelBootingCallbackTestStub::unboot(); + EloquentChildModelBootingCallbackTestStub::unboot(); + } + + public function testModelsTraitIsInitialized() + { + $model = new EloquentModelStubWithTrait; + $this->assertTrue($model->fooBarIsInitialized); + } + + public function testAppendingOfAttributes() + { + $model = new EloquentModelAppendsStub; + + $this->assertTrue(isset($model->is_admin)); + $this->assertTrue(isset($model->camelCased)); + $this->assertTrue(isset($model->StudlyCased)); + + $this->assertSame('admin', $model->is_admin); + $this->assertSame('camelCased', $model->camelCased); + $this->assertSame('StudlyCased', $model->StudlyCased); + + $this->assertEquals(['is_admin', 'camelCased', 'StudlyCased'], $model->getAppends()); + + $this->assertTrue($model->hasAppended('is_admin')); + $this->assertTrue($model->hasAppended('camelCased')); + $this->assertTrue($model->hasAppended('StudlyCased')); + $this->assertFalse($model->hasAppended('not_appended')); + + $model->setHidden(['is_admin', 'camelCased', 'StudlyCased']); + $this->assertEquals([], $model->toArray()); + + $model->setVisible([]); + $this->assertEquals([], $model->toArray()); + } + + public function testMergeAppendsMergesAppends() + { + $model = new EloquentModelAppendsStub; + + $appendsCount = count($model->getAppends()); + $this->assertEquals(['is_admin', 'camelCased', 'StudlyCased'], $model->getAppends()); + + $model->mergeAppends(['bar']); + $this->assertCount($appendsCount + 1, $model->getAppends()); + $this->assertContains('bar', $model->getAppends()); + } + + public function testGetMutatedAttributes() + { + $model = new EloquentModelGetMutatorsStub; + + $this->assertEquals(['first_name', 'middle_name', 'last_name'], $model->getMutatedAttributes()); + + EloquentModelGetMutatorsStub::resetMutatorCache(); + + EloquentModelGetMutatorsStub::$snakeAttributes = false; + $this->assertEquals(['firstName', 'middleName', 'lastName'], $model->getMutatedAttributes()); + } + + public function testReplicateCreatesANewModelInstanceWithSameAttributeValues() + { + $model = new EloquentModelStub; + $model->id = 'id'; + $model->foo = 'bar'; + $model->created_at = new DateTime; + $model->updated_at = new DateTime; + $replicated = $model->replicate(); + + $this->assertNull($replicated->id); + $this->assertSame('bar', $replicated->foo); + $this->assertNull($replicated->created_at); + $this->assertNull($replicated->updated_at); + } + + public function testReplicatingEventIsFiredWhenReplicatingModel() + { + $model = new EloquentModelStub; + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch')->once()->with('eloquent.replicating: '.get_class($model), m::on(function ($m) use ($model) { + return $model->is($m); + })); + + $model->replicate(); + } + + public function testReplicateQuietlyCreatesANewModelInstanceWithSameAttributeValuesAndIsQuiet() + { + $model = new EloquentModelStub; + $model->id = 'id'; + $model->foo = 'bar'; + $model->created_at = new DateTime; + $model->updated_at = new DateTime; + $replicated = $model->replicateQuietly(); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch')->never()->with('eloquent.replicating: '.get_class($model), $model)->andReturn(true); + + $this->assertNull($replicated->id); + $this->assertSame('bar', $replicated->foo); + $this->assertNull($replicated->created_at); + $this->assertNull($replicated->updated_at); + } + + public function testIncrementOnExistingModelCallsQueryAndSetsAttribute() + { + $model = m::mock(EloquentModelStub::class.'[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 2; + + $model->shouldReceive('newQueryWithoutScopes')->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('where')->andReturn($query); + $query->shouldReceive('increment')->andReturn(1); + + // hmm + $model->publicIncrement('foo', 1); + $this->assertFalse($model->isDirty()); + + $model->publicIncrement('foo', 1, ['category' => 1]); + $this->assertEquals(4, $model->foo); + $this->assertEquals(1, $model->category); + $this->assertTrue($model->isDirty('category')); + } + + public function testIncrementQuietlyOnExistingModelCallsQueryAndSetsAttributeAndIsQuiet() + { + $model = m::mock(EloquentModelStub::class.'[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 2; + + $model->shouldReceive('newQueryWithoutScopes')->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('where')->andReturn($query); + $query->shouldReceive('increment')->andReturn(1); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->never()->with('eloquent.saving: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->never()->with('eloquent.updating: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->never()->with('eloquent.updated: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->never()->with('eloquent.saved: '.get_class($model), $model)->andReturn(true); + + $model->publicIncrementQuietly('foo', 1); + $this->assertFalse($model->isDirty()); + + $model->publicIncrementQuietly('foo', 1, ['category' => 1]); + $this->assertEquals(4, $model->foo); + $this->assertEquals(1, $model->category); + $this->assertTrue($model->isDirty('category')); + } + + public function testDecrementQuietlyOnExistingModelCallsQueryAndSetsAttributeAndIsQuiet() + { + $model = m::mock(EloquentModelStub::class.'[newQueryWithoutScopes]'); + $model->exists = true; + $model->id = 1; + $model->syncOriginalAttribute('id'); + $model->foo = 4; + + $model->shouldReceive('newQueryWithoutScopes')->andReturn($query = m::mock(Builder::class)); + $query->shouldReceive('where')->andReturn($query); + $query->shouldReceive('decrement')->andReturn(1); + + $model->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('until')->never()->with('eloquent.saving: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('until')->never()->with('eloquent.updating: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->never()->with('eloquent.updated: '.get_class($model), $model)->andReturn(true); + $events->shouldReceive('dispatch')->never()->with('eloquent.saved: '.get_class($model), $model)->andReturn(true); + + $model->publicDecrementQuietly('foo', 1); + $this->assertFalse($model->isDirty()); + + $model->publicDecrementQuietly('foo', 1, ['category' => 1]); + $this->assertEquals(2, $model->foo); + $this->assertEquals(1, $model->category); + $this->assertTrue($model->isDirty('category')); + } + + public function testRelationshipTouchOwnersIsPropagated() + { + $relation = $this->getMockBuilder(BelongsTo::class)->onlyMethods(['touch'])->disableOriginalConstructor()->getMock(); + $relation->expects($this->once())->method('touch'); + + $model = m::mock(EloquentModelStub::class.'[partner]'); + $this->addMockConnection($model); + $model->shouldReceive('partner')->once()->andReturn($relation); + $model->setTouchedRelations(['partner']); + + $mockPartnerModel = m::mock(EloquentModelStub::class.'[touchOwners]'); + $mockPartnerModel->shouldReceive('touchOwners')->once(); + $model->setRelation('partner', $mockPartnerModel); + + $model->touchOwners(); + } + + public function testRelationshipTouchOwnersIsNotPropagatedIfNoRelationshipResult() + { + $relation = $this->getMockBuilder(BelongsTo::class)->onlyMethods(['touch'])->disableOriginalConstructor()->getMock(); + $relation->expects($this->once())->method('touch'); + + $model = m::mock(EloquentModelStub::class.'[partner]'); + $this->addMockConnection($model); + $model->shouldReceive('partner')->once()->andReturn($relation); + $model->setTouchedRelations(['partner']); + + $model->setRelation('partner', null); + + $model->touchOwners(); + } + + public function testModelAttributesAreCastedWhenPresentInCastsPropertyOrCastsMethod() + { + $model = new EloquentModelCastingStub; + $model->setDateFormat('Y-m-d H:i:s'); + $model->intAttribute = '3'; + $model->floatAttribute = '4.0'; + $model->stringAttribute = 2.5; + $model->boolAttribute = 1; + $model->booleanAttribute = 0; + $model->objectAttribute = ['foo' => 'bar']; + $obj = new stdClass; + $obj->foo = 'bar'; + $model->arrayAttribute = $obj; + $model->jsonAttribute = ['foo' => 'bar']; + $model->jsonAttributeWithUnicode = ['こんにちは' => '世界']; + $model->dateAttribute = '1969-07-20'; + $model->datetimeAttribute = '1969-07-20 22:56:00'; + $model->timestampAttribute = '1969-07-20 22:56:00'; + $model->collectionAttribute = new BaseCollection; + $model->asCustomCollectionAttribute = new CustomCollection; + + $this->assertIsInt($model->intAttribute); + $this->assertIsFloat($model->floatAttribute); + $this->assertIsString($model->stringAttribute); + $this->assertIsBool($model->boolAttribute); + $this->assertIsBool($model->booleanAttribute); + $this->assertIsObject($model->objectAttribute); + $this->assertIsArray($model->arrayAttribute); + $this->assertIsArray($model->jsonAttribute); + $this->assertIsArray($model->jsonAttributeWithUnicode); + $this->assertTrue($model->boolAttribute); + $this->assertFalse($model->booleanAttribute); + $this->assertEquals($obj, $model->objectAttribute); + $this->assertEquals(['foo' => 'bar'], $model->arrayAttribute); + $this->assertEquals(['foo' => 'bar'], $model->jsonAttribute); + $this->assertSame('{"foo":"bar"}', $model->jsonAttributeValue()); + $this->assertEquals(['こんにちは' => '世界'], $model->jsonAttributeWithUnicode); + $this->assertSame('{"こんにちは":"世界"}', $model->jsonAttributeWithUnicodeValue()); + $this->assertInstanceOf(Carbon::class, $model->dateAttribute); + $this->assertInstanceOf(Carbon::class, $model->datetimeAttribute); + $this->assertInstanceOf(BaseCollection::class, $model->collectionAttribute); + $this->assertInstanceOf(CustomCollection::class, $model->asCustomCollectionAttribute); + $this->assertSame('1969-07-20', $model->dateAttribute->toDateString()); + $this->assertSame('1969-07-20 22:56:00', $model->datetimeAttribute->toDateTimeString()); + $this->assertEquals(-14173440, $model->timestampAttribute); + + $arr = $model->toArray(); + + $this->assertIsInt($arr['intAttribute']); + $this->assertIsFloat($arr['floatAttribute']); + $this->assertIsString($arr['stringAttribute']); + $this->assertIsBool($arr['boolAttribute']); + $this->assertIsBool($arr['booleanAttribute']); + $this->assertIsObject($arr['objectAttribute']); + $this->assertIsArray($arr['arrayAttribute']); + $this->assertIsArray($arr['jsonAttribute']); + $this->assertIsArray($arr['jsonAttributeWithUnicode']); + $this->assertIsArray($arr['collectionAttribute']); + $this->assertTrue($arr['boolAttribute']); + $this->assertFalse($arr['booleanAttribute']); + $this->assertEquals($obj, $arr['objectAttribute']); + $this->assertEquals(['foo' => 'bar'], $arr['arrayAttribute']); + $this->assertEquals(['foo' => 'bar'], $arr['jsonAttribute']); + $this->assertEquals(['こんにちは' => '世界'], $arr['jsonAttributeWithUnicode']); + $this->assertSame('1969-07-20 00:00:00', $arr['dateAttribute']); + $this->assertSame('1969-07-20 22:56:00', $arr['datetimeAttribute']); + $this->assertEquals(-14173440, $arr['timestampAttribute']); + } + + public function testModelDateAttributeCastingResetsTime() + { + $model = new EloquentModelCastingStub; + $model->setDateFormat('Y-m-d H:i:s'); + $model->dateAttribute = '1969-07-20 22:56:00'; + + $this->assertSame('1969-07-20 00:00:00', $model->dateAttribute->toDateTimeString()); + + $arr = $model->toArray(); + $this->assertSame('1969-07-20 00:00:00', $arr['dateAttribute']); + } + + public function testModelAttributeCastingPreservesNull() + { + $model = new EloquentModelCastingStub; + $model->intAttribute = null; + $model->floatAttribute = null; + $model->stringAttribute = null; + $model->boolAttribute = null; + $model->booleanAttribute = null; + $model->objectAttribute = null; + $model->arrayAttribute = null; + $model->jsonAttribute = null; + $model->jsonAttributeWithUnicode = null; + $model->dateAttribute = null; + $model->datetimeAttribute = null; + $model->timestampAttribute = null; + $model->collectionAttribute = null; + + $attributes = $model->getAttributes(); + + $this->assertNull($attributes['intAttribute']); + $this->assertNull($attributes['floatAttribute']); + $this->assertNull($attributes['stringAttribute']); + $this->assertNull($attributes['boolAttribute']); + $this->assertNull($attributes['booleanAttribute']); + $this->assertNull($attributes['objectAttribute']); + $this->assertNull($attributes['arrayAttribute']); + $this->assertNull($attributes['jsonAttribute']); + $this->assertNull($attributes['jsonAttributeWithUnicode']); + $this->assertNull($attributes['dateAttribute']); + $this->assertNull($attributes['datetimeAttribute']); + $this->assertNull($attributes['timestampAttribute']); + $this->assertNull($attributes['collectionAttribute']); + + $this->assertNull($model->intAttribute); + $this->assertNull($model->floatAttribute); + $this->assertNull($model->stringAttribute); + $this->assertNull($model->boolAttribute); + $this->assertNull($model->booleanAttribute); + $this->assertNull($model->objectAttribute); + $this->assertNull($model->arrayAttribute); + $this->assertNull($model->jsonAttribute); + $this->assertNull($model->jsonAttributeWithUnicode); + $this->assertNull($model->dateAttribute); + $this->assertNull($model->datetimeAttribute); + $this->assertNull($model->timestampAttribute); + $this->assertNull($model->collectionAttribute); + + $array = $model->toArray(); + + $this->assertNull($array['intAttribute']); + $this->assertNull($array['floatAttribute']); + $this->assertNull($array['stringAttribute']); + $this->assertNull($array['boolAttribute']); + $this->assertNull($array['booleanAttribute']); + $this->assertNull($array['objectAttribute']); + $this->assertNull($array['arrayAttribute']); + $this->assertNull($array['jsonAttribute']); + $this->assertNull($array['jsonAttributeWithUnicode']); + $this->assertNull($array['dateAttribute']); + $this->assertNull($array['datetimeAttribute']); + $this->assertNull($array['timestampAttribute']); + $this->assertNull($attributes['collectionAttribute']); + } + + public function testModelAttributeCastingFailsOnUnencodableData() + { + $this->expectException(JsonEncodingException::class); + $this->expectExceptionMessage('Unable to encode attribute [objectAttribute] for model [Hypervel\Tests\Database\Laravel\EloquentModelCastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.'); + + $model = new EloquentModelCastingStub; + $model->objectAttribute = ['foo' => "b\xF8r"]; + $obj = new stdClass; + $obj->foo = "b\xF8r"; + $model->arrayAttribute = $obj; + + $model->getAttributes(); + } + + public function testModelJsonCastingFailsOnUnencodableData() + { + $this->expectException(JsonEncodingException::class); + $this->expectExceptionMessage('Unable to encode attribute [jsonAttribute] for model [Hypervel\Tests\Database\Laravel\EloquentModelCastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.'); + + $model = new EloquentModelCastingStub; + $model->jsonAttribute = ['foo' => "b\xF8r"]; + + $model->getAttributes(); + } + + public function testModelAttributeCastingFailsOnUnencodableDataWithUnicode() + { + $this->expectException(JsonEncodingException::class); + $this->expectExceptionMessage('Unable to encode attribute [jsonAttributeWithUnicode] for model [Hypervel\Tests\Database\Laravel\EloquentModelCastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.'); + + $model = new EloquentModelCastingStub; + $model->jsonAttributeWithUnicode = ['foo' => "b\xF8r"]; + + $model->getAttributes(); + } + + public function testJsonCastingRespectsUnicodeOption() + { + $data = ['こんにちは' => '世界']; + $model = new EloquentModelCastingStub; + $model->jsonAttribute = $data; + $model->jsonAttributeWithUnicode = $data; + + $this->assertSame('{"\u3053\u3093\u306b\u3061\u306f":"\u4e16\u754c"}', $model->jsonAttributeValue()); + $this->assertSame('{"こんにちは":"世界"}', $model->jsonAttributeWithUnicodeValue()); + $this->assertSame(['こんにちは' => '世界'], $model->jsonAttribute); + $this->assertSame(['こんにちは' => '世界'], $model->jsonAttributeWithUnicode); + } + + public function testModelAttributeCastingWithFloats() + { + $model = new EloquentModelCastingStub; + + $model->floatAttribute = 0; + $this->assertSame(0.0, $model->floatAttribute); + + $model->floatAttribute = 'Infinity'; + $this->assertSame(INF, $model->floatAttribute); + + $model->floatAttribute = INF; + $this->assertSame(INF, $model->floatAttribute); + + $model->floatAttribute = '-Infinity'; + $this->assertSame(-INF, $model->floatAttribute); + + $model->floatAttribute = -INF; + $this->assertSame(-INF, $model->floatAttribute); + + $model->floatAttribute = 'NaN'; + $this->assertNan($model->floatAttribute); + + $model->floatAttribute = NAN; + $this->assertNan($model->floatAttribute); + } + + public function testModelAttributeCastingWithArrays() + { + $model = new EloquentModelCastingStub; + + $model->asEnumArrayObjectAttribute = ['draft', 'pending']; + $this->assertInstanceOf(ArrayObject::class, $model->asEnumArrayObjectAttribute); + } + + public function testMergeCastsMergesCasts() + { + $model = new EloquentModelCastingStub; + + $castCount = count($model->getCasts()); + $this->assertArrayNotHasKey('foo', $model->getCasts()); + + $model->mergeCasts(['foo' => 'date']); + $this->assertCount($castCount + 1, $model->getCasts()); + $this->assertArrayHasKey('foo', $model->getCasts()); + } + + public function testMergeCastsMergesCastsUsingArrays() + { + $model = new EloquentModelCastingStub; + + $castCount = count($model->getCasts()); + $this->assertArrayNotHasKey('foo', $model->getCasts()); + + $model->mergeCasts([ + 'foo' => ['MyClass', 'myArgumentA'], + 'bar' => ['MyClass', 'myArgumentA', 'myArgumentB'], + ]); + + $this->assertCount($castCount + 2, $model->getCasts()); + $this->assertArrayHasKey('foo', $model->getCasts()); + $this->assertEquals($model->getCasts()['foo'], 'MyClass:myArgumentA'); + $this->assertEquals($model->getCasts()['bar'], 'MyClass:myArgumentA,myArgumentB'); + } + + public function testUnsetCastAttributes() + { + $model = new EloquentModelCastingStub; + $model->asToObjectCast = TestValueObject::make([ + 'myPropertyA' => 'A', + 'myPropertyB' => 'B', + ]); + unset($model->asToObjectCast); + $this->assertArrayNotHasKey('asToObjectCast', $model->getAttributes()); + } + + public function testUpdatingNonExistentModelFails() + { + $model = new EloquentModelStub; + $this->assertFalse($model->update()); + } + + public function testIssetBehavesCorrectlyWithAttributesAndRelationships() + { + $model = new EloquentModelStub; + $this->assertFalse(isset($model->nonexistent)); + + $model->some_attribute = 'some_value'; + $this->assertTrue(isset($model->some_attribute)); + + $model->setRelation('some_relation', 'some_value'); + $this->assertTrue(isset($model->some_relation)); + } + + public function testNonExistingAttributeWithInternalMethodNameDoesntCallMethod() + { + $model = m::mock(EloquentModelStub::class.'[delete,getRelationValue]'); + $model->name = 'Spark'; + $model->shouldNotReceive('delete'); + $model->shouldReceive('getRelationValue')->once()->with('belongsToStub')->andReturn('relation'); + + // Can return a normal relation + $this->assertSame('relation', $model->belongsToStub); + + // Can return a normal attribute + $this->assertSame('Spark', $model->name); + + // Returns null for a Model.php method name + $this->assertNull($model->delete); + + $model = m::mock(EloquentModelStub::class.'[delete]'); + $model->delete = 123; + $this->assertEquals(123, $model->delete); + } + + public function testIntKeyTypePreserved() + { + $model = $this->getMockBuilder(EloquentModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with([], 'id')->andReturn(1); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + + $this->assertTrue($model->save()); + $this->assertEquals(1, $model->id); + } + + public function testStringKeyTypePreserved() + { + $model = $this->getMockBuilder(EloquentKeyTypeModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); + + $query = m::mock(Builder::class); + $query->shouldReceive('insertGetId')->once()->with([], 'id')->andReturn('string id'); + $query->shouldReceive('getConnection')->once(); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + + $this->assertTrue($model->save()); + $this->assertSame('string id', $model->id); + } + + public function testScopesMethod() + { + $model = new EloquentModelStub; + $this->addMockConnection($model); + + $scopes = [ + 'published', + 'category' => 'Laravel', + 'framework' => ['Laravel', '5.3'], + 'date' => Carbon::now(), + ]; + + $this->assertInstanceOf(Builder::class, $model->scopes($scopes)); + $this->assertSame($scopes, $model->scopesCalled); + } + + public function testScopesMethodWithString() + { + $model = new EloquentModelStub; + $this->addMockConnection($model); + + $this->assertInstanceOf(Builder::class, $model->scopes('published')); + $this->assertSame(['published'], $model->scopesCalled); + } + + public function testIsWithNull() + { + $firstInstance = new EloquentModelStub(['id' => 1]); + $secondInstance = null; + + $this->assertFalse($firstInstance->is($secondInstance)); + } + + public function testIsWithTheSameModelInstance() + { + $firstInstance = new EloquentModelStub(['id' => 1]); + $secondInstance = new EloquentModelStub(['id' => 1]); + $result = $firstInstance->is($secondInstance); + $this->assertTrue($result); + } + + public function testIsWithAnotherModelInstance() + { + $firstInstance = new EloquentModelStub(['id' => 1]); + $secondInstance = new EloquentModelStub(['id' => 2]); + $result = $firstInstance->is($secondInstance); + $this->assertFalse($result); + } + + public function testIsWithAnotherTable() + { + $firstInstance = new EloquentModelStub(['id' => 1]); + $secondInstance = new EloquentModelStub(['id' => 1]); + $secondInstance->setTable('foo'); + $result = $firstInstance->is($secondInstance); + $this->assertFalse($result); + } + + public function testIsWithAnotherConnection() + { + $firstInstance = new EloquentModelStub(['id' => 1]); + $secondInstance = new EloquentModelStub(['id' => 1]); + $secondInstance->setConnection('foo'); + $result = $firstInstance->is($secondInstance); + $this->assertFalse($result); + } + + public function testWithoutTouchingCallback() + { + new EloquentModelStub(['id' => 1]); + + $called = false; + + EloquentModelStub::withoutTouching(function () use (&$called) { + $called = true; + }); + + $this->assertTrue($called); + } + + public function testWithoutTouchingOnCallback() + { + new EloquentModelStub(['id' => 1]); + + $called = false; + + Model::withoutTouchingOn([EloquentModelStub::class], function () use (&$called) { + $called = true; + }); + + $this->assertTrue($called); + } + + public function testThrowsWhenAccessingMissingAttributes() + { + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(); + + try { + $model = new EloquentModelStub(['id' => 1]); + $model->exists = true; + + $this->assertEquals(1, $model->id); + $this->expectException(MissingAttributeException::class); + + $model->this_attribute_does_not_exist; + } finally { + Model::preventAccessingMissingAttributes($originalMode); + } + } + + public function testThrowsWhenAccessingMissingAttributesWhichArePrimitiveCasts() + { + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(); + + $model = new EloquentModelWithPrimitiveCasts(['id' => 1]); + $model->exists = true; + + $exceptionCount = 0; + $primitiveCasts = EloquentModelWithPrimitiveCasts::makePrimitiveCastsArray(); + try { + try { + $this->assertEquals(null, $model->backed_enum); + } catch (MissingAttributeException) { + $exceptionCount++; + } + + foreach ($primitiveCasts as $key => $type) { + try { + $v = $model->{$key}; + } catch (MissingAttributeException) { + $exceptionCount++; + } + } + + $this->assertInstanceOf(Address::class, $model->address); + + $this->assertEquals(1, $model->id); + $this->assertEquals('ok', $model->this_is_fine); + $this->assertEquals('ok', $model->this_is_also_fine); + + // Primitive castables, enum castable + $expectedExceptionCount = count($primitiveCasts) + 1; + $this->assertEquals($expectedExceptionCount, $exceptionCount); + } finally { + Model::preventAccessingMissingAttributes($originalMode); + } + } + + public function testUsesOverriddenHandlerWhenAccessingMissingAttributes() + { + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(); + + $callbackModel = null; + $callbackKey = null; + + Model::handleMissingAttributeViolationUsing(function ($model, $key) use (&$callbackModel, &$callbackKey) { + $callbackModel = $model; + $callbackKey = $key; + }); + + $model = new EloquentModelStub(['id' => 1]); + $model->exists = true; + + $this->assertEquals(1, $model->id); + + $model->this_attribute_does_not_exist; + + $this->assertInstanceOf(EloquentModelStub::class, $callbackModel); + $this->assertEquals('this_attribute_does_not_exist', $callbackKey); + + Model::preventAccessingMissingAttributes($originalMode); + Model::handleMissingAttributeViolationUsing(null); + } + + public function testDoesntThrowWhenAccessingMissingAttributesOnModelThatIsNotSaved() + { + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(); + + try { + $model = new EloquentModelStub(['id' => 1]); + $model->exists = false; + + $this->assertEquals(1, $model->id); + $this->assertNull($model->this_attribute_does_not_exist); + } finally { + Model::preventAccessingMissingAttributes($originalMode); + } + } + + public function testDoesntThrowWhenAccessingMissingAttributesOnModelThatWasRecentlyCreated() + { + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(); + + try { + $model = new EloquentModelStub(['id' => 1]); + $model->exists = true; + $model->wasRecentlyCreated = true; + + $this->assertEquals(1, $model->id); + $this->assertNull($model->this_attribute_does_not_exist); + } finally { + Model::preventAccessingMissingAttributes($originalMode); + } + } + + public function testDoesntThrowWhenAssigningMissingAttributes() + { + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(); + + try { + $model = new EloquentModelStub(['id' => 1]); + $model->exists = true; + + $model->this_attribute_does_not_exist = 'now it does'; + } finally { + Model::preventAccessingMissingAttributes($originalMode); + } + } + + public function testDoesntThrowWhenTestingMissingAttributes() + { + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(); + + try { + $model = new EloquentModelStub(['id' => 1]); + $model->exists = true; + + $this->assertTrue(isset($model->id)); + $this->assertFalse(isset($model->this_attribute_does_not_exist)); + } finally { + Model::preventAccessingMissingAttributes($originalMode); + } + } + + protected function addMockConnection($model) + { + $model->setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $resolver->shouldReceive('connection')->andReturn($connection = m::mock(Connection::class)); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $grammar->shouldReceive('getBitwiseOperators')->andReturn([]); + $grammar->shouldReceive('isExpression')->andReturnFalse(); + $connection->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new BaseBuilder($connection, $grammar, $processor); + }); + } + + public function testTouchingModelWithTimestamps() + { + $this->assertFalse( + Model::isIgnoringTouch(Model::class) + ); + } + + public function testNotTouchingModelWithUpdatedAtNull() + { + $this->assertTrue( + Model::isIgnoringTouch(EloquentModelWithUpdatedAtNull::class) + ); + } + + public function testNotTouchingModelWithoutTimestamps() + { + $this->assertTrue( + Model::isIgnoringTouch(EloquentModelWithoutTimestamps::class) + ); + } + + public function testGetOriginalCastsAttributes() + { + $model = new EloquentModelCastingStub; + $model->intAttribute = '1'; + $model->floatAttribute = '0.1234'; + $model->stringAttribute = 432; + $model->boolAttribute = '1'; + $model->booleanAttribute = '0'; + $stdClass = new stdClass; + $stdClass->json_key = 'json_value'; + $model->objectAttribute = $stdClass; + $array = [ + 'foo' => 'bar', + ]; + $collection = collect($array); + $model->arrayAttribute = $array; + $model->jsonAttribute = $array; + $model->jsonAttributeWithUnicode = $array; + $model->collectionAttribute = $collection; + + $model->syncOriginal(); + + $model->intAttribute = 2; + $model->floatAttribute = 0.443; + $model->stringAttribute = '12'; + $model->boolAttribute = true; + $model->booleanAttribute = false; + $model->objectAttribute = $stdClass; + $model->arrayAttribute = [ + 'foo' => 'bar2', + ]; + $model->jsonAttribute = [ + 'foo' => 'bar2', + ]; + $model->jsonAttributeWithUnicode = [ + 'foo' => 'bar2', + ]; + $model->collectionAttribute = collect([ + 'foo' => 'bar2', + ]); + + $this->assertIsInt($model->getOriginal('intAttribute')); + $this->assertEquals(1, $model->getOriginal('intAttribute')); + $this->assertEquals(2, $model->intAttribute); + $this->assertEquals(2, $model->getAttribute('intAttribute')); + + $this->assertIsFloat($model->getOriginal('floatAttribute')); + $this->assertEquals(0.1234, $model->getOriginal('floatAttribute')); + $this->assertEquals(0.443, $model->floatAttribute); + + $this->assertIsString($model->getOriginal('stringAttribute')); + $this->assertSame('432', $model->getOriginal('stringAttribute')); + $this->assertSame('12', $model->stringAttribute); + + $this->assertIsBool($model->getOriginal('boolAttribute')); + $this->assertTrue($model->getOriginal('boolAttribute')); + $this->assertTrue($model->boolAttribute); + + $this->assertIsBool($model->getOriginal('booleanAttribute')); + $this->assertFalse($model->getOriginal('booleanAttribute')); + $this->assertFalse($model->booleanAttribute); + + $this->assertEquals($stdClass, $model->getOriginal('objectAttribute')); + $this->assertEquals($model->getAttribute('objectAttribute'), $model->getOriginal('objectAttribute')); + + $this->assertEquals($array, $model->getOriginal('arrayAttribute')); + $this->assertEquals(['foo' => 'bar'], $model->getOriginal('arrayAttribute')); + $this->assertEquals(['foo' => 'bar2'], $model->getAttribute('arrayAttribute')); + + $this->assertEquals($array, $model->getOriginal('jsonAttribute')); + $this->assertEquals(['foo' => 'bar'], $model->getOriginal('jsonAttribute')); + $this->assertEquals(['foo' => 'bar2'], $model->getAttribute('jsonAttribute')); + + $this->assertEquals($array, $model->getOriginal('jsonAttributeWithUnicode')); + $this->assertEquals(['foo' => 'bar'], $model->getOriginal('jsonAttributeWithUnicode')); + $this->assertEquals(['foo' => 'bar2'], $model->getAttribute('jsonAttributeWithUnicode')); + + $this->assertEquals(['foo' => 'bar'], $model->getOriginal('collectionAttribute')->toArray()); + $this->assertEquals(['foo' => 'bar2'], $model->getAttribute('collectionAttribute')->toArray()); + } + + public function testCastsMethodHasPriorityOverCastsProperty() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'duplicatedAttribute' => '1', + ], true); + + $this->assertIsInt($model->duplicatedAttribute); + $this->assertEquals(1, $model->duplicatedAttribute); + $this->assertEquals(1, $model->getAttribute('duplicatedAttribute')); + } + + public function testCastsMethodIsTakenInConsiderationOnSerialization() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'duplicatedAttribute' => '1', + ], true); + + $model = unserialize(serialize($model)); + + $this->assertIsInt($model->duplicatedAttribute); + $this->assertEquals(1, $model->duplicatedAttribute); + $this->assertEquals(1, $model->getAttribute('duplicatedAttribute')); + } + + public function testCastOnArrayFormatWithOneElement() + { + $model = new EloquentModelCastingStub; + $model->setRawAttributes([ + 'singleElementInArrayAttribute' => '{"bar": "foo"}', + ]); + $model->syncOriginal(); + + $this->assertInstanceOf(BaseCollection::class, $model->singleElementInArrayAttribute); + $this->assertEquals(['bar' => 'foo'], $model->singleElementInArrayAttribute->toArray()); + $this->assertEquals(['bar' => 'foo'], $model->getAttribute('singleElementInArrayAttribute')->toArray()); + } + + public function testUsingStringableObjectCastUsesStringRepresentation() + { + $model = new EloquentModelCastingStub; + + $this->assertEquals('int', $model->getCasts()['castStringableObject']); + } + + public function testMergeingStringableObjectCastUSesStringRepresentation() + { + $stringable = new StringableCastBuilder(); + $stringable->cast = 'test'; + + $model = (new EloquentModelCastingStub)->mergeCasts([ + 'something' => $stringable, + ]); + + $this->assertEquals('test', $model->getCasts()['something']); + } + + public function testUsingPlainObjectAsCastThrowsException() + { + $model = new EloquentModelCastingStub; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The cast object for the something attribute must implement Stringable.'); + + $model->mergeCasts([ + 'something' => (object) [], + ]); + } + + public function testUnsavedModel() + { + $user = new UnsavedModel; + $user->name = null; + + $this->assertNull($user->name); + } + + public function testDiscardChanges() + { + $user = new EloquentModelStub([ + 'name' => 'Taylor Otwell', + ]); + + $this->assertNotEmpty($user->isDirty()); + $this->assertNull($user->getOriginal('name')); + $this->assertSame('Taylor Otwell', $user->getAttribute('name')); + + $user->discardChanges(); + + $this->assertEmpty($user->isDirty()); + $this->assertNull($user->getOriginal('name')); + $this->assertNull($user->getAttribute('name')); + } + + public function testDiscardChangesWithCasts() + { + $model = new EloquentModelWithPrimitiveCasts(); + + $model->address_line_one = '123 Main Street'; + + $this->assertEquals('123 Main Street', $model->address->lineOne); + $this->assertEquals('123 MAIN STREET', $model->address_in_caps); + + $model->discardChanges(); + + $this->assertNull($model->address->lineOne); + $this->assertNull($model->address_in_caps); + } + + public function testHasAttribute() + { + $user = new EloquentModelStub([ + 'name' => 'Mateus', + ]); + + $this->assertTrue($user->hasAttribute('name')); + $this->assertTrue($user->hasAttribute('password')); + $this->assertTrue($user->hasAttribute('castedFloat')); + $this->assertFalse($user->hasAttribute('nonexistent')); + $this->assertFalse($user->hasAttribute('belongsToStub')); + } + + public function testModelToJsonSucceedsWithPriorErrors(): void + { + $user = new EloquentModelStub(['name' => 'Mateus']); + + // Simulate a JSON error + json_decode('{'); + $this->assertTrue(json_last_error() !== JSON_ERROR_NONE); + + $this->assertSame('{"name":"Mateus"}', $user->toJson(JSON_THROW_ON_ERROR)); + } + + public function testModelToPrettyJson(): void + { + $user = new EloquentModelStub(['name' => 'Mateus', 'active' => true, 'number' => '123']); + $results = $user->toPrettyJson(); + $expected = $user->toJson(JSON_PRETTY_PRINT); + + $this->assertJsonStringEqualsJsonString($expected, $results); + $this->assertSame($expected, $results); + $this->assertStringContainsString("\n", $results); + $this->assertStringContainsString(' ', $results); + + $results = $user->toPrettyJson(JSON_NUMERIC_CHECK); + $this->assertStringContainsString("\n", $results); + $this->assertStringContainsString(' ', $results); + $this->assertStringContainsString('"number": 123', $results); + } + + public function testFillableWithMutators() + { + $model = new EloquentModelWithMutators; + $model->fillable(['full_name', 'full_address']); + $model->fill(['id' => 1, 'full_name' => 'John Doe', 'full_address' => '123 Main Street, Anytown']); + + $this->assertNull($model->id); + $this->assertSame('John', $model->first_name); + $this->assertSame('Doe', $model->last_name); + $this->assertSame('123 Main Street', $model->address_line_one); + $this->assertSame('Anytown', $model->address_line_two); + } + + public function testGuardedWithMutators() + { + $model = new EloquentModelWithMutators; + $model->guard(['id']); + $model->fill(['id' => 1, 'full_name' => 'John Doe', 'full_address' => '123 Main Street, Anytown']); + + $this->assertNull($model->id); + $this->assertSame('John', $model->first_name); + $this->assertSame('Doe', $model->last_name); + $this->assertSame('123 Main Street', $model->address_line_one); + $this->assertSame('Anytown', $model->address_line_two); + } + + public function testCollectedByAttribute() + { + $model = new EloquentModelWithCollectedByAttribute; + $collection = $model->newCollection([$model]); + + $this->assertInstanceOf(CustomEloquentCollection::class, $collection); + } + + public function testUseFactoryAttribute() + { + $model = new EloquentModelWithUseFactoryAttribute; + $instance = EloquentModelWithUseFactoryAttribute::factory()->make(['name' => 'test name']); + $factory = EloquentModelWithUseFactoryAttribute::factory(); + $this->assertInstanceOf(EloquentModelWithUseFactoryAttribute::class, $instance); + $this->assertInstanceOf(EloquentModelWithUseFactoryAttributeFactory::class, $model::factory()); + $this->assertInstanceOf(EloquentModelWithUseFactoryAttributeFactory::class, $model::newFactory()); + $this->assertEquals(EloquentModelWithUseFactoryAttribute::class, $factory->modelName()); + $this->assertEquals('test name', $instance->name); // Small smoke test to ensure the factory is working + } + + public function testUseCustomBuilderWithUseEloquentBuilderAttribute() + { + $model = new EloquentModelWithUseEloquentBuilderAttributeStub(); + + $query = $this->createMock(BaseBuilder::class); + $eloquentBuilder = $model->newEloquentBuilder($query); + + $this->assertInstanceOf(CustomBuilder::class, $eloquentBuilder); + } + + public function testDefaultBuilderIsUsedWhenUseEloquentBuilderAttributeIsNotPresent() + { + $model = new EloquentModelWithoutUseEloquentBuilderAttributeStub(); + + $query = $this->createMock(BaseBuilder::class); + $eloquentBuilder = $model->newEloquentBuilder($query); + + $this->assertNotInstanceOf(CustomBuilder::class, $eloquentBuilder); + } +} + +class CustomBuilder extends Builder +{ +} + +#[\Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder(CustomBuilder::class)] +class EloquentModelWithUseEloquentBuilderAttributeStub extends Model +{ +} + +class EloquentModelWithoutUseEloquentBuilderAttributeStub extends Model +{ +} + +class EloquentTestObserverStub +{ + public function creating() + { + // + } + + public function saved() + { + // + } +} + +class EloquentTestAnotherObserverStub +{ + public function creating() + { + // + } + + public function saved() + { + // + } +} + +class EloquentTestThirdObserverStub +{ + public function creating() + { + // + } + + public function saved() + { + // + } +} + +class EloquentModelStub extends Model +{ + public \UnitEnum|string|null $connection = null; + public array $scopesCalled = []; + protected ?string $table = 'stub'; + protected array $guarded = []; + protected array $casts = ['castedFloat' => 'float']; + + public function getListItemsAttribute($value) + { + return json_decode($value, true); + } + + public function setListItemsAttribute($value) + { + $this->attributes['list_items'] = json_encode($value); + } + + public function getPasswordAttribute() + { + return '******'; + } + + public function setPasswordAttribute($value) + { + $this->attributes['password_hash'] = sha1($value); + } + + public function publicIncrement($column, $amount = 1, $extra = []) + { + return $this->increment($column, $amount, $extra); + } + + public function publicIncrementQuietly($column, $amount = 1, $extra = []) + { + return $this->incrementQuietly($column, $amount, $extra); + } + + public function publicDecrementQuietly($column, $amount = 1, $extra = []) + { + return $this->decrementQuietly($column, $amount, $extra); + } + + public function belongsToStub() + { + return $this->belongsTo(EloquentModelSaveStub::class); + } + + public function morphToStub() + { + return $this->morphTo(); + } + + public function morphToStubWithKeys() + { + return $this->morphTo(null, 'type', 'id'); + } + + public function morphToStubWithName() + { + return $this->morphTo('someName'); + } + + public function morphToStubWithNameAndKeys() + { + return $this->morphTo('someName', 'type', 'id'); + } + + public function belongsToExplicitKeyStub() + { + return $this->belongsTo(EloquentModelSaveStub::class, 'foo'); + } + + public function incorrectRelationStub() + { + return 'foo'; + } + + public function getDates(): array + { + return []; + } + + public function getAppendableAttribute() + { + return 'appended'; + } + + public function scopePublished(Builder $builder) + { + $this->scopesCalled[] = 'published'; + } + + public function scopeCategory(Builder $builder, $category) + { + $this->scopesCalled['category'] = $category; + } + + public function scopeFramework(Builder $builder, $framework, $version) + { + $this->scopesCalled['framework'] = [$framework, $version]; + } + + public function scopeDate(Builder $builder, Carbon $date) + { + $this->scopesCalled['date'] = $date; + } +} + +trait FooBarTrait +{ + public $fooBarIsInitialized = false; + + public function initializeFooBarTrait() + { + $this->fooBarIsInitialized = true; + } +} + +class EloquentModelStubWithTrait extends EloquentModelStub +{ + use FooBarTrait; +} + +class EloquentModelCamelStub extends EloquentModelStub +{ + public static bool $snakeAttributes = false; +} + +class EloquentDateModelStub extends EloquentModelStub +{ + public function getDates(): array + { + return ['created_at', 'updated_at']; + } +} + +class EloquentModelSaveStub extends Model +{ + protected ?string $table = 'save_stub'; + protected array $guarded = []; + + public function save(array $options = []): bool + { + if ($this->fireModelEvent('saving') === false) { + return false; + } + + $_SERVER['__eloquent.saved'] = true; + + $this->fireModelEvent('saved', false); + + return true; + } + + public function setIncrementing(bool $value): static + { + $this->incrementing = $value; + + return $this; + } + + public function getConnection(): Connection + { + $mock = m::mock(Connection::class); + $mock->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $grammar->shouldReceive('getBitwiseOperators')->andReturn([]); + $grammar->shouldReceive('isExpression')->andReturnFalse(); + $mock->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); + $mock->shouldReceive('getName')->andReturn('name'); + $mock->shouldReceive('query')->andReturnUsing(function () use ($mock, $grammar, $processor) { + return new BaseBuilder($mock, $grammar, $processor); + }); + + return $mock; + } +} + +class EloquentKeyTypeModelStub extends EloquentModelStub +{ + protected string $keyType = 'string'; +} + +class EloquentModelFindWithWritePdoStub extends Model +{ + public function newQuery(): Builder + { + $mock = m::mock(Builder::class); + $mock->shouldReceive('useWritePdo')->once()->andReturnSelf(); + $mock->shouldReceive('find')->once()->with(1)->andReturn('foo'); + + return $mock; + } +} + +class EloquentModelDestroyStub extends Model +{ + protected array $fillable = [ + 'id', + ]; + + public function newQuery(): Builder + { + $mock = m::mock(Builder::class); + $mock->shouldReceive('whereIn')->once()->with('id', [1, 2, 3])->andReturn($mock); + $mock->shouldReceive('get')->once()->andReturn([$model = m::mock(stdClass::class)]); + $model->shouldReceive('delete')->once(); + + return $mock; + } +} + +class EloquentModelEmptyDestroyStub extends Model +{ + public function newQuery(): Builder + { + $mock = m::mock(Builder::class); + $mock->shouldReceive('whereIn')->never(); + + return $mock; + } +} + +class EloquentModelWithStub extends Model +{ + public function newQuery(): Builder + { + $mock = m::mock(Builder::class); + $mock->shouldReceive('with')->once()->with(['foo', 'bar'])->andReturnSelf(); + + return $mock; + } +} + +class EloquentModelWithWhereHasStub extends Model +{ + public function foo() + { + return $this->hasMany(EloquentModelStub::class); + } +} + +class EloquentModelWithoutRelationStub extends Model +{ + public array $with = ['foo']; + + protected array $guarded = []; + + public function getEagerLoads() + { + return $this->eagerLoads; + } +} + +class EloquentModelWithoutTableStub extends Model +{ + // +} + +class EloquentModelBootingTestStub extends Model +{ + public static function unboot() + { + unset(static::$booted[static::class]); + unset(static::$bootedCallbacks[static::class]); + } + + public static function isBooted() + { + return array_key_exists(static::class, static::$booted); + } +} + +class EloquentModelAppendsStub extends Model +{ + protected array $appends = ['is_admin', 'camelCased', 'StudlyCased']; + + public function getIsAdminAttribute() + { + return 'admin'; + } + + public function getCamelCasedAttribute() + { + return 'camelCased'; + } + + public function getStudlyCasedAttribute() + { + return 'StudlyCased'; + } +} + +class EloquentModelGetMutatorsStub extends Model +{ + public static function resetMutatorCache() + { + static::$mutatorCache = []; + } + + public function getFirstNameAttribute() + { + // + } + + public function getMiddleNameAttribute() + { + // + } + + public function getLastNameAttribute() + { + // + } + + public function doNotgetFirstInvalidAttribute() + { + // + } + + public function doNotGetSecondInvalidAttribute() + { + // + } + + public function doNotgetThirdInvalidAttributeEither() + { + // + } + + public function doNotGetFourthInvalidAttributeEither() + { + // + } +} + +class EloquentModelCastingStub extends Model +{ + protected array $casts = [ + 'floatAttribute' => 'float', + 'boolAttribute' => 'bool', + 'objectAttribute' => 'object', + 'jsonAttribute' => 'json', + 'jsonAttributeWithUnicode' => 'json:unicode', + 'dateAttribute' => 'date', + 'timestampAttribute' => 'timestamp', + 'ascollectionAttribute' => AsCollection::class, + 'asCustomCollectionAsArrayAttribute' => [AsCollection::class, CustomCollection::class], + 'asEncryptedCollectionAttribute' => AsEncryptedCollection::class, + 'asEnumCollectionAttribute' => AsEnumCollection::class.':'.StringStatus::class, + 'asEnumArrayObjectAttribute' => AsEnumArrayObject::class.':'.StringStatus::class, + 'duplicatedAttribute' => 'string', + ]; + + protected function casts(): array + { + return [ + 'intAttribute' => 'int', + 'stringAttribute' => 'string', + 'booleanAttribute' => 'boolean', + 'arrayAttribute' => 'array', + 'collectionAttribute' => 'collection', + 'datetimeAttribute' => 'datetime', + 'asarrayobjectAttribute' => AsArrayObject::class, + 'asStringableAttribute' => AsStringable::class, + 'asHtmlStringAttribute' => AsHtmlString::class, + 'asUriAttribute' => AsUri::class, + 'asFluentAttribute' => AsFluent::class, + 'asCustomCollectionAttribute' => AsCollection::using(CustomCollection::class), + 'asEncryptedArrayObjectAttribute' => AsEncryptedArrayObject::class, + 'asEncryptedCustomCollectionAttribute' => AsEncryptedCollection::using(CustomCollection::class), + 'asEncryptedCustomCollectionAsArrayAttribute' => [AsEncryptedCollection::class, CustomCollection::class], + 'asCustomEnumCollectionAttribute' => AsEnumCollection::of(StringStatus::class), + 'asCustomEnumArrayObjectAttribute' => AsEnumArrayObject::of(StringStatus::class), + 'singleElementInArrayAttribute' => [AsCollection::class], + 'duplicatedAttribute' => 'int', + 'asToObjectCast' => TestCast::class, + 'castStringableObject' => new StringableCastBuilder(), + ]; + } + + public function jsonAttributeValue() + { + return $this->attributes['jsonAttribute']; + } + + public function jsonAttributeWithUnicodeValue() + { + return $this->attributes['jsonAttributeWithUnicode']; + } + + protected function serializeDate(DateTimeInterface $date): string + { + return $date->format('Y-m-d H:i:s'); + } +} + +class EloquentModelEnumCastingStub extends Model +{ + protected array $casts = ['enumAttribute' => StringStatus::class]; +} + +class EloquentModelDynamicHiddenStub extends Model +{ + protected ?string $table = 'stub'; + protected array $guarded = []; + + public function getHidden(): array + { + return ['age', 'id']; + } +} + +class EloquentModelVisibleStub extends Model +{ + protected ?string $table = 'stub'; + protected array $visible = ['foo']; +} + +class EloquentModelHiddenStub extends Model +{ + protected ?string $table = 'stub'; + protected array $hidden = ['foo']; +} + +class EloquentModelDynamicVisibleStub extends Model +{ + protected ?string $table = 'stub'; + protected array $guarded = []; + + public function getVisible(): array + { + return ['name', 'id']; + } +} + +class EloquentModelNonIncrementingStub extends Model +{ + protected ?string $table = 'stub'; + protected array $guarded = []; + public bool $incrementing = false; +} + +class EloquentNoConnectionModelStub extends EloquentModelStub +{ + // +} + +class EloquentDifferentConnectionModelStub extends EloquentModelStub +{ + public \UnitEnum|string|null $connection = 'different_connection'; +} + +class EloquentPrimaryUuidModelStub extends EloquentModelStub +{ + use HasUuids; + + public bool $incrementing = false; + protected string $keyType = 'string'; + + public function getKeyName(): string + { + return 'uuid'; + } +} + +class EloquentNonPrimaryUuidModelStub extends EloquentModelStub +{ + use HasUuids; + + public function getKeyName(): string + { + return 'id'; + } + + public function uniqueIds(): array + { + return ['uuid']; + } +} + +class EloquentPrimaryUlidModelStub extends EloquentModelStub +{ + use HasUlids; + + public bool $incrementing = false; + protected string $keyType = 'string'; + + public function getKeyName(): string + { + return 'ulid'; + } +} + +class EloquentNonPrimaryUlidModelStub extends EloquentModelStub +{ + use HasUlids; + + public function getKeyName(): string + { + return 'id'; + } + + public function uniqueIds(): array + { + return ['ulid']; + } +} + +#[ObservedBy(EloquentTestObserverStub::class)] +class EloquentModelWithObserveAttributeStub extends EloquentModelStub +{ + // +} + +#[ObservedBy([EloquentTestObserverStub::class])] +class EloquentModelWithObserveAttributeUsingArrayStub extends EloquentModelStub +{ + // +} + +#[ObservedBy([EloquentTestObserverStub::class])] +class EloquentModelWithObserveAttributeGrandparentStub extends EloquentModelStub +{ + // +} + +#[ObservedBy([EloquentTestAnotherObserverStub::class])] +class EloquentModelWithObserveAttributeParentStub extends EloquentModelWithObserveAttributeGrandparentStub +{ + // +} + +#[ObservedBy([EloquentTestThirdObserverStub::class])] +class EloquentModelWithObserveAttributeGrandchildStub extends EloquentModelWithObserveAttributeParentStub +{ + // +} + +class EloquentModelSavingEventStub +{ + // +} + +class EloquentModelEventObjectStub extends Model +{ + protected array $dispatchesEvents = [ + 'saving' => EloquentModelSavingEventStub::class, + ]; +} + +class EloquentModelWithoutTimestamps extends Model +{ + protected ?string $table = 'stub'; + public bool $timestamps = false; +} + +class EloquentModelWithUpdatedAtNull extends Model +{ + protected ?string $table = 'stub'; + const UPDATED_AT = null; +} + +class UnsavedModel extends Model +{ + protected array $casts = ['name' => Uppercase::class]; +} + +class Uppercase implements CastsInboundAttributes +{ + public function set($model, string $key, $value, array $attributes) + { + return is_string($value) ? strtoupper($value) : $value; + } +} + +class CustomCollection extends BaseCollection +{ + // +} + +class EloquentModelWithPrimitiveCasts extends Model +{ + public array $fillable = ['id']; + + public array $casts = [ + 'backed_enum' => CastableBackedEnum::class, + 'address' => Address::class, + ]; + + public array $attributes = [ + 'address_line_one' => null, + 'address_line_two' => null, + ]; + + public static function makePrimitiveCastsArray(): array + { + $toReturn = []; + + foreach (static::$primitiveCastTypes as $index => $primitiveCastType) { + $toReturn['primitive_cast_'.$index] = $primitiveCastType; + } + + return $toReturn; + } + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->mergeCasts(self::makePrimitiveCastsArray()); + } + + public function getThisIsFineAttribute($value) + { + return 'ok'; + } + + public function thisIsAlsoFine(): Attribute + { + return Attribute::get(fn () => 'ok'); + } + + public function addressInCaps(): Attribute + { + return Attribute::get( + function () { + $value = $this->getAttributes()['address_line_one'] ?? null; + + return is_string($value) ? strtoupper($value) : $value; + } + )->shouldCache(); + } +} + +enum CastableBackedEnum: string +{ + case Value1 = 'value1'; +} + +class Address implements Castable +{ + public function __construct( + public ?string $lineOne = null, + public ?string $lineTwo = null + ) { + } + + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes + { + public function get(Model $model, string $key, mixed $value, array $attributes): Address + { + return new Address( + $attributes['address_line_one'], + $attributes['address_line_two'] + ); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + return [ + 'address_line_one' => $value->lineOne ?? null, + 'address_line_two' => $value->lineTwo ?? null, + ]; + } + }; + } +} + +class EloquentModelWithRecursiveRelationshipsStub extends Model +{ + public array $fillable = ['id', 'parent_id']; + + protected static \WeakMap $recursionDetectionCache; + + public function getQueueableRelations(): array + { + try { + $this->stepIn(); + + return parent::getQueueableRelations(); + } finally { + $this->stepOut(); + } + } + + public function push(): bool + { + try { + $this->stepIn(); + + return parent::push(); + } finally { + $this->stepOut(); + } + } + + public function save(array $options = []): bool + { + return true; + } + + public function relationsToArray(): array + { + try { + $this->stepIn(); + + return parent::relationsToArray(); + } finally { + $this->stepOut(); + } + } + + public function parent(): BelongsTo + { + return $this->belongsTo(static::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(static::class, 'parent_id'); + } + + public function self(): BelongsTo + { + return $this->belongsTo(static::class, 'id'); + } + + protected static function getRecursionDetectionCache() + { + return static::$recursionDetectionCache ??= new \WeakMap; + } + + protected function getRecursionDepth(): int + { + $cache = static::getRecursionDetectionCache(); + + return $cache->offsetExists($this) ? $cache->offsetGet($this) : 0; + } + + protected function stepIn(): void + { + $depth = $this->getRecursionDepth(); + + if ($depth > 1) { + throw new \RuntimeException('Recursion detected'); + } + static::getRecursionDetectionCache()->offsetSet($this, $depth + 1); + } + + protected function stepOut(): void + { + $cache = static::getRecursionDetectionCache(); + if ($depth = $this->getRecursionDepth()) { + $cache->offsetSet($this, $depth - 1); + } else { + $cache->offsetUnset($this); + } + } +} + +class EloquentModelWithMutators extends Model +{ + public array $attributes = [ + 'first_name' => null, + 'last_name' => null, + 'address_line_one' => null, + 'address_line_two' => null, + ]; + + protected function fullName(): Attribute + { + return Attribute::make( + set: function (string $fullName) { + [$firstName, $lastName] = explode(' ', $fullName); + + return [ + 'first_name' => $firstName, + 'last_name' => $lastName, + ]; + } + ); + } + + public function setFullAddressAttribute($fullAddress) + { + [$addressLineOne, $addressLineTwo] = explode(', ', $fullAddress); + + $this->attributes['address_line_one'] = $addressLineOne; + $this->attributes['address_line_two'] = $addressLineTwo; + } +} + +#[CollectedBy(CustomEloquentCollection::class)] +class EloquentModelWithCollectedByAttribute extends Model +{ +} + +class CustomEloquentCollection extends Collection +{ +} + +class EloquentModelWithUseFactoryAttributeFactory extends Factory +{ + public function definition(): array + { + return []; + } +} + +#[UseFactory(EloquentModelWithUseFactoryAttributeFactory::class)] +class EloquentModelWithUseFactoryAttribute extends Model +{ + use HasFactory; +} + +trait EloquentTraitBootingCallbackTestStub +{ + public static function bootEloquentTraitBootingCallbackTestStub() + { + static::whenBooted(fn () => static::$bootHasFinished = true); + } +} + +class EloquentModelBootingCallbackTestStub extends Model +{ + use EloquentTraitBootingCallbackTestStub; + + public static bool $bootHasFinished = false; + + public static function unboot() + { + unset(static::$booted[static::class]); + unset(static::$bootedCallbacks[static::class]); + static::$bootHasFinished = false; + } +} + +class EloquentChildModelBootingCallbackTestStub extends EloquentModelBootingCallbackTestStub +{ + public static bool $bootHasFinished = false; +} + +class StringableCastBuilder implements NativeStringable +{ + public $cast = 'int'; + + public function __toString() + { + return $this->cast; + } +} + +enum ConnectionName +{ + case Foo; + case Bar; +} + +enum ConnectionNameBacked: string +{ + case Foo = 'Foo'; + case Bar = 'Bar'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentMorphOneOfManyTest.php b/tests/Database/Laravel/DatabaseEloquentMorphOneOfManyTest.php new file mode 100644 index 000000000..aafb45bed --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentMorphOneOfManyTest.php @@ -0,0 +1,258 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('products', function ($table) { + $table->increments('id'); + }); + + $this->schema()->create('states', function ($table) { + $table->increments('id'); + $table->morphs('stateful'); + $table->string('state'); + $table->string('type')->nullable(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('products'); + $this->schema()->drop('states'); + + parent::tearDown(); + } + + public function testEagerLoadingAppliesConstraintsToInnerJoinSubQuery() + { + $product = MorphOneOfManyTestProduct::create(); + $relation = $product->current_state(); + $relation->addEagerConstraints([$product]); + $this->assertSame('select MAX("states"."id") as "id_aggregate", "states"."stateful_id", "states"."stateful_type" from "states" where "states"."stateful_type" = ? and "states"."stateful_id" = ? and "states"."stateful_id" is not null and "states"."stateful_id" in (1) and "states"."stateful_type" = ? group by "states"."stateful_id", "states"."stateful_type"', $relation->getOneOfManySubQuery()->toSql()); + } + + public function testReceivingModel() + { + $product = MorphOneOfManyTestProduct::create(); + $product->states()->create([ + 'state' => 'draft', + ]); + $product->states()->create([ + 'state' => 'active', + ]); + + $this->assertNotNull($product->current_state); + $this->assertSame('active', $product->current_state->state); + } + + public function testMorphType() + { + $product = MorphOneOfManyTestProduct::create(); + $product->states()->create([ + 'state' => 'draft', + ]); + $product->states()->create([ + 'state' => 'active', + ]); + $state = $product->states()->make([ + 'state' => 'foo', + ]); + $state->stateful_type = 'bar'; + $state->save(); + + $this->assertNotNull($product->current_state); + $this->assertSame('active', $product->current_state->state); + } + + public function testForceCreateMorphType() + { + $product = MorphOneOfManyTestProduct::create(); + $state = $product->states()->forceCreate([ + 'state' => 'active', + ]); + + $this->assertNotNull($state); + $this->assertSame(MorphOneOfManyTestProduct::class, $product->current_state->stateful_type); + } + + public function testExists() + { + $product = MorphOneOfManyTestProduct::create(); + $previousState = $product->states()->create([ + 'state' => 'draft', + ]); + $currentState = $product->states()->create([ + 'state' => 'active', + ]); + + $exists = MorphOneOfManyTestProduct::whereHas('current_state', function ($q) use ($previousState) { + $q->whereKey($previousState->getKey()); + })->exists(); + $this->assertFalse($exists); + + $exists = MorphOneOfManyTestProduct::whereHas('current_state', function ($q) use ($currentState) { + $q->whereKey($currentState->getKey()); + })->exists(); + $this->assertTrue($exists); + } + + public function testWithWhereHas() + { + $product = MorphOneOfManyTestProduct::create(); + $previousState = $product->states()->create([ + 'state' => 'draft', + ]); + $currentState = $product->states()->create([ + 'state' => 'active', + ]); + + $exists = MorphOneOfManyTestProduct::withWhereHas('current_state', function ($q) use ($previousState) { + $q->whereKey($previousState->getKey()); + })->exists(); + $this->assertFalse($exists); + + $exists = MorphOneOfManyTestProduct::withWhereHas('current_state', function ($q) use ($currentState) { + $q->whereKey($currentState->getKey()); + })->get(); + + $this->assertCount(1, $exists); + $this->assertTrue($exists->first()->relationLoaded('current_state')); + $this->assertSame($exists->first()->current_state->state, $currentState->state); + } + + public function testWithWhereRelation() + { + $product = MorphOneOfManyTestProduct::create(); + $currentState = $product->states()->create([ + 'state' => 'active', + ]); + + $exists = MorphOneOfManyTestProduct::withWhereRelation('current_state', 'state', 'active')->exists(); + $this->assertTrue($exists); + + $exists = MorphOneOfManyTestProduct::withWhereRelation('current_state', 'state', 'active')->get(); + + $this->assertCount(1, $exists); + $this->assertTrue($exists->first()->relationLoaded('current_state')); + $this->assertSame($exists->first()->current_state->state, $currentState->state); + } + + public function testWithExists() + { + $product = MorphOneOfManyTestProduct::create(); + + $product = MorphOneOfManyTestProduct::withExists('current_state')->first(); + $this->assertFalse($product->current_state_exists); + + $product->states()->create([ + 'state' => 'draft', + ]); + $product = MorphOneOfManyTestProduct::withExists('current_state')->first(); + $this->assertTrue($product->current_state_exists); + } + + public function testWithExistsWithConstraintsInJoinSubSelect() + { + $product = MorphOneOfManyTestProduct::create(); + + $product = MorphOneOfManyTestProduct::withExists('current_foo_state')->first(); + $this->assertFalse($product->current_foo_state_exists); + + $product->states()->create([ + 'state' => 'draft', + 'type' => 'foo', + ]); + $product = MorphOneOfManyTestProduct::withExists('current_foo_state')->first(); + $this->assertTrue($product->current_foo_state_exists); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class MorphOneOfManyTestProduct extends Eloquent +{ + protected $table = 'products'; + protected $guarded = []; + public $timestamps = false; + + public function states() + { + return $this->morphMany(MorphOneOfManyTestState::class, 'stateful'); + } + + public function current_state() + { + return $this->morphOne(MorphOneOfManyTestState::class, 'stateful')->ofMany(); + } + + public function current_foo_state() + { + return $this->morphOne(MorphOneOfManyTestState::class, 'stateful')->ofMany( + ['id' => 'max'], + function ($q) { + $q->where('type', 'foo'); + } + ); + } +} + +class MorphOneOfManyTestState extends Eloquent +{ + protected $table = 'states'; + protected $guarded = []; + public $timestamps = false; + protected $fillable = ['state', 'type']; +} diff --git a/tests/Database/Laravel/DatabaseEloquentMorphTest.php b/tests/Database/Laravel/DatabaseEloquentMorphTest.php new file mode 100755 index 000000000..61045166c --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentMorphTest.php @@ -0,0 +1,538 @@ +getOneRelation(); + } + + public function testMorphOneEagerConstraintsAreProperlyAdded() + { + $relation = $this->getOneRelation(); + $relation->getParent()->shouldReceive('getKeyName')->once()->andReturn('id'); + $relation->getParent()->shouldReceive('getKeyType')->once()->andReturn('string'); + $relation->getQuery()->shouldReceive('whereIn')->once()->with('table.morph_id', [1, 2]); + $relation->getQuery()->shouldReceive('where')->once()->with('table.morph_type', get_class($relation->getParent())); + + $model1 = new EloquentMorphResetModelStub; + $model1->id = 1; + $model2 = new EloquentMorphResetModelStub; + $model2->id = 2; + $relation->addEagerConstraints([$model1, $model2]); + } + + /** + * Note that the tests are the exact same for morph many because the classes share this code... + * Will still test to be safe. + */ + public function testMorphManySetsProperConstraints() + { + $this->getManyRelation(); + } + + public function testMorphManyEagerConstraintsAreProperlyAdded() + { + $relation = $this->getManyRelation(); + $relation->getParent()->shouldReceive('getKeyName')->once()->andReturn('id'); + $relation->getParent()->shouldReceive('getKeyType')->once()->andReturn('int'); + $relation->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('table.morph_id', [1, 2]); + $relation->getQuery()->shouldReceive('where')->once()->with('table.morph_type', get_class($relation->getParent())); + + $model1 = new EloquentMorphResetModelStub; + $model1->id = 1; + $model2 = new EloquentMorphResetModelStub; + $model2->id = 2; + $relation->addEagerConstraints([$model1, $model2]); + } + + public function testMorphRelationUpsertFillsForeignKey() + { + $relation = $this->getManyRelation(); + + $relation->getQuery()->shouldReceive('upsert')->once()->with( + [ + ['email' => 'foo3', 'name' => 'bar', $relation->getForeignKeyName() => $relation->getParentKey(), $relation->getMorphType() => $relation->getMorphClass()], + ], + ['email'], + ['name'] + ); + + $relation->upsert( + ['email' => 'foo3', 'name' => 'bar'], + ['email'], + ['name'] + ); + + $relation->getQuery()->shouldReceive('upsert')->once()->with( + [ + ['email' => 'foo3', 'name' => 'bar', $relation->getForeignKeyName() => $relation->getParentKey(), $relation->getMorphType() => $relation->getMorphClass()], + ['name' => 'bar2', 'email' => 'foo2', $relation->getForeignKeyName() => $relation->getParentKey(), $relation->getMorphType() => $relation->getMorphClass()], + ], + ['email'], + ['name'] + ); + + $relation->upsert( + [ + ['email' => 'foo3', 'name' => 'bar'], + ['name' => 'bar2', 'email' => 'foo2'], + ], + ['email'], + ['name'] + ); + } + + public function testMakeFunctionOnMorph() + { + $_SERVER['__eloquent.saved'] = false; + // Doesn't matter which relation type we use since they share the code... + $relation = $this->getOneRelation(); + $instance = m::mock(Model::class); + $instance->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $instance->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $instance->shouldReceive('save')->never(); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['name' => 'taylor'])->andReturn($instance); + + $this->assertEquals($instance, $relation->make(['name' => 'taylor'])); + } + + public function testCreateFunctionOnMorph() + { + // Doesn't matter which relation type we use since they share the code... + $relation = $this->getOneRelation(); + $created = m::mock(Model::class); + $created->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $created->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['name' => 'taylor'])->andReturn($created); + $created->shouldReceive('save')->once()->andReturn(true); + + $this->assertEquals($created, $relation->create(['name' => 'taylor'])); + } + + public function testFindOrNewMethodFindsModel() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('find')->once()->with('foo', ['*'])->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + $model->shouldReceive('setAttribute')->never(); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->findOrNew('foo')); + } + + public function testFindOrNewMethodReturnsNewModelWithMorphKeysSet() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('find')->once()->with('foo', ['*'])->andReturn(null); + $relation->getRelated()->shouldReceive('newInstance')->once()->with()->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->findOrNew('foo')); + } + + public function testFirstOrNewMethodFindsFirstModel() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + $model->shouldReceive('setAttribute')->never(); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrNew(['foo'])); + } + + public function testFirstOrNewMethodWithValueFindsFirstModel() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + $model->shouldReceive('setAttribute')->never(); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrNew(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testFirstOrNewMethodReturnsNewModelWithMorphKeysSet() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrNew(['foo'])); + } + + public function testFirstOrNewMethodWithValuesReturnsNewModelWithMorphKeysSet() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrNew(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testFirstOrCreateMethodFindsFirstModel() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + $model->shouldReceive('setAttribute')->never(); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrCreate(['foo'])); + } + + public function testFirstOrCreateMethodWithValuesFindsFirstModel() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + $model->shouldReceive('setAttribute')->never(); + $model->shouldReceive('save')->never(); + + $this->assertInstanceOf(Model::class, $relation->firstOrCreate(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testFirstOrCreateMethodCreatesNewMorphModel() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(fn ($scope) => $scope()); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andReturn(true); + + $this->assertInstanceOf(Model::class, $relation->firstOrCreate(['foo'])); + } + + public function testFirstOrCreateMethodWithValuesCreatesNewMorphModel() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(fn ($scope) => $scope()); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andReturn(true); + + $this->assertInstanceOf(Model::class, $relation->firstOrCreate(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testCreateOrFirstMethodFindsFirstModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andThrow( + new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + ); + + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); + $relation->getQuery()->shouldReceive('useWritePdo')->once()->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo'])); + } + + public function testCreateOrFirstMethodWithValuesFindsFirstModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andThrow( + new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + ); + + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); + $relation->getQuery()->shouldReceive('useWritePdo')->once()->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testCreateOrFirstMethodCreatesNewMorphModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andReturn(true); + + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo'])); + } + + public function testCreateOrFirstMethodWithValuesCreatesNewMorphModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andReturn(true); + + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->never(); + + $model->wasRecentlyCreated = false; + $model->shouldReceive('setAttribute')->never(); + $model->shouldReceive('fill')->once()->with(['bar'])->andReturn($model); + $model->shouldReceive('save')->once(); + + $this->assertInstanceOf(Model::class, $relation->updateOrCreate(['foo'], ['bar'])); + } + + public function testUpdateOrCreateMethodCreatesNewMorphModel() + { + $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo', 'bar'])->andReturn($model = m::mock(Model::class)); + + $model->wasRecentlyCreated = true; + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andReturn(true); + + $this->assertInstanceOf(Model::class, $relation->updateOrCreate(['foo'], ['bar'])); + } + + public function testCreateFunctionOnNamespacedMorph() + { + $relation = $this->getNamespacedRelation('namespace'); + $created = m::mock(Model::class); + $created->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $created->shouldReceive('setAttribute')->once()->with('morph_type', 'namespace'); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['name' => 'taylor'])->andReturn($created); + $created->shouldReceive('save')->once()->andReturn(true); + + $this->assertEquals($created, $relation->create(['name' => 'taylor'])); + } + + public function testIsNotNull() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->never(); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is(null)); + } + + public function testIsModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->once()->andReturn('table'); + $relation->getRelated()->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithStringRelatedKey() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->once()->andReturn('table'); + $relation->getRelated()->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn('1'); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsNotModelWithNullRelatedKey() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->never(); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(null); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherRelatedKey() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->never(); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(2); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherTable() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->once()->andReturn('table'); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table.two'); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherConnection() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('getTable')->once()->andReturn('table'); + $relation->getRelated()->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('morph_id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('table'); + $model->shouldReceive('getConnectionName')->once()->andReturn('connection.two'); + + $this->assertFalse($relation->is($model)); + } + + protected function getOneRelation() + { + $queryBuilder = m::mock(QueryBuilder::class); + $builder = m::mock(Builder::class, [$queryBuilder]); + $builder->shouldReceive('whereNotNull')->once()->with('table.morph_id'); + $builder->shouldReceive('where')->once()->with('table.morph_id', '=', 1); + $related = m::mock(Model::class); + $builder->shouldReceive('getModel')->andReturn($related); + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $parent->shouldReceive('getMorphClass')->andReturn(get_class($parent)); + $builder->shouldReceive('where')->once()->with('table.morph_type', get_class($parent)); + + return new MorphOne($builder, $parent, 'table.morph_type', 'table.morph_id', 'id'); + } + + protected function getManyRelation() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('whereNotNull')->once()->with('table.morph_id'); + $builder->shouldReceive('where')->once()->with('table.morph_id', '=', 1); + $related = m::mock(Model::class); + $builder->shouldReceive('getModel')->andReturn($related); + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $parent->shouldReceive('getMorphClass')->andReturn(get_class($parent)); + $builder->shouldReceive('where')->once()->with('table.morph_type', get_class($parent)); + + return new MorphMany($builder, $parent, 'table.morph_type', 'table.morph_id', 'id'); + } + + protected function getNamespacedRelation($alias) + { + require_once __DIR__.'/stubs/EloquentModelNamespacedStub.php'; + + Relation::morphMap([ + $alias => EloquentModelNamespacedStub::class, + ]); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('whereNotNull')->once()->with('table.morph_id'); + $builder->shouldReceive('where')->once()->with('table.morph_id', '=', 1); + $related = m::mock(Model::class); + $builder->shouldReceive('getModel')->andReturn($related); + $parent = m::mock(EloquentModelNamespacedStub::class); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $parent->shouldReceive('getMorphClass')->andReturn($alias); + $builder->shouldReceive('where')->once()->with('table.morph_type', $alias); + + return new MorphOne($builder, $parent, 'table.morph_type', 'table.morph_id', 'id'); + } +} + +class EloquentMorphResetModelStub extends Model +{ + // +} diff --git a/tests/Database/Laravel/DatabaseEloquentMorphToManyTest.php b/tests/Database/Laravel/DatabaseEloquentMorphToManyTest.php new file mode 100644 index 000000000..b5f747133 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentMorphToManyTest.php @@ -0,0 +1,141 @@ +getRelation(); + $relation->getParent()->shouldReceive('getKeyName')->andReturn('id'); + $relation->getParent()->shouldReceive('getKeyType')->once()->andReturn('int'); + $relation->getQuery()->shouldReceive('whereIntegerInRaw')->once()->with('taggables.taggable_id', [1, 2]); + $relation->getQuery()->shouldReceive('where')->once()->with('taggables.taggable_type', get_class($relation->getParent())); + $model1 = new EloquentMorphToManyModelStub; + $model1->id = 1; + $model2 = new EloquentMorphToManyModelStub; + $model2->id = 2; + $relation->addEagerConstraints([$model1, $model2]); + } + + public function testAttachInsertsPivotTableRecord(): void + { + $relation = $this->getMockBuilder(MorphToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $query = m::mock(stdClass::class); + $query->shouldReceive('from')->once()->with('taggables')->andReturn($query); + $query->shouldReceive('insert')->once()->with([['taggable_id' => 1, 'taggable_type' => get_class($relation->getParent()), 'tag_id' => 2, 'foo' => 'bar']])->andReturn(true); + $relation->getQuery()->getQuery()->shouldReceive('newQuery')->once()->andReturn($query); + $relation->expects($this->once())->method('touchIfTouching'); + + $relation->attach(2, ['foo' => 'bar']); + } + + public function testDetachRemovesPivotTableRecord(): void + { + $relation = $this->getMockBuilder(MorphToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $query = m::mock(stdClass::class); + $query->shouldReceive('from')->once()->with('taggables')->andReturn($query); + $query->shouldReceive('where')->once()->with('taggables.taggable_id', 1)->andReturn($query); + $query->shouldReceive('where')->once()->with('taggable_type', get_class($relation->getParent()))->andReturn($query); + $query->shouldReceive('whereIn')->once()->with('taggables.tag_id', [1, 2, 3]); + $query->shouldReceive('delete')->once()->andReturn(true); + $relation->getQuery()->getQuery()->shouldReceive('newQuery')->once()->andReturn($query); + $relation->expects($this->once())->method('touchIfTouching'); + + $this->assertTrue($relation->detach([1, 2, 3])); + } + + public function testDetachMethodClearsAllPivotRecordsWhenNoIDsAreGiven(): void + { + $relation = $this->getMockBuilder(MorphToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $query = m::mock(stdClass::class); + $query->shouldReceive('from')->once()->with('taggables')->andReturn($query); + $query->shouldReceive('where')->once()->with('taggables.taggable_id', 1)->andReturn($query); + $query->shouldReceive('where')->once()->with('taggable_type', get_class($relation->getParent()))->andReturn($query); + $query->shouldReceive('whereIn')->never(); + $query->shouldReceive('delete')->once()->andReturn(true); + $relation->getQuery()->getQuery()->shouldReceive('newQuery')->once()->andReturn($query); + $relation->expects($this->once())->method('touchIfTouching'); + + $this->assertTrue($relation->detach()); + } + + public function testQueryExpressionCanBePassedToDifferentPivotQueryBuilderClauses(): void + { + $value = 'pivot_value'; + $column = new Expression("CONCAT(foo, '_', bar)"); + $relation = $this->getRelation(); + /** @var Builder|m\MockInterface $builder */ + $builder = $relation->getQuery(); + + $builder->shouldReceive('where')->with($column, '=', $value, 'and')->times(2)->andReturnSelf(); + $relation->wherePivot($column, '=', $value); + $relation->withPivotValue($column, $value); + + $builder->shouldReceive('whereBetween')->with($column, [$value, $value], 'and', false)->once()->andReturnSelf(); + $relation->wherePivotBetween($column, [$value, $value]); + + $builder->shouldReceive('whereIn')->with($column, [$value], 'and', false)->once()->andReturnSelf(); + $relation->wherePivotIn($column, [$value]); + + $builder->shouldReceive('whereNull')->with($column, 'and', false)->once()->andReturnSelf(); + $relation->wherePivotNull($column); + + $builder->shouldReceive('orderBy')->with($column, 'asc')->once()->andReturnSelf(); + $relation->orderByPivot($column); + } + + public function getRelation(): MorphToMany + { + [$builder, $parent] = $this->getRelationArguments(); + + return new MorphToMany($builder, $parent, 'taggable', 'taggables', 'taggable_id', 'tag_id', 'id', 'id'); + } + + public function getRelationArguments(): array + { + $parent = m::mock(Model::class); + $parent->shouldReceive('getMorphClass')->andReturn(get_class($parent)); + $parent->shouldReceive('getKey')->andReturn(1); + $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); + $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + $parent->shouldReceive('getMorphClass')->andReturn(get_class($parent)); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + + $builder = m::mock(Builder::class); + $related = m::mock(Model::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $related->shouldReceive('getTable')->andReturn('tags'); + $related->shouldReceive('getKeyName')->andReturn('id'); + $related->shouldReceive('qualifyColumn')->with('id')->andReturn('tags.id'); + $related->shouldReceive('getMorphClass')->andReturn(get_class($related)); + + $builder->shouldReceive('join')->once()->with('taggables', 'tags.id', '=', 'taggables.tag_id'); + $builder->shouldReceive('where')->once()->with('taggables.taggable_id', '=', 1); + $builder->shouldReceive('where')->once()->with('taggables.taggable_type', get_class($parent)); + + $grammar = m::mock(Grammar::class); + $grammar->shouldReceive('isExpression')->with(m::type(Expression::class))->andReturnTrue(); + $grammar->shouldReceive('isExpression')->with(m::type('string'))->andReturnFalse(); + $builder->shouldReceive('getQuery')->andReturn( + m::mock(stdClass::class, ['getGrammar' => $grammar]) + ); + + return [$builder, $parent, 'taggable', 'taggables', 'taggable_id', 'tag_id', 'id', 'id', 'relation_name', false]; + } +} + +class EloquentMorphToManyModelStub extends Model +{ + protected $guarded = []; +} diff --git a/tests/Database/Laravel/DatabaseEloquentMorphToTest.php b/tests/Database/Laravel/DatabaseEloquentMorphToTest.php new file mode 100644 index 000000000..081437be6 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentMorphToTest.php @@ -0,0 +1,402 @@ +getRelation(); + $relation->addEagerConstraints([ + $one = (object) ['morph_type' => 'morph_type_2', 'foreign_key' => TestEnum::test], + ]); + $dictionary = $relation->getDictionary(); + $relation->getDictionary(); + $enumKey = TestEnum::test; + if (isset($enumKey->value)) { + $value = $dictionary['morph_type_2'][$enumKey->value][0]->foreign_key; + $this->assertEquals(TestEnum::test, $value); + } else { + $this->fail('An enum should contain value property'); + } + } + + public function testLookupDictionaryIsProperlyConstructed() + { + $stringish = new class + { + public function __toString() + { + return 'foreign_key_2'; + } + }; + + $relation = $this->getRelation(); + $relation->addEagerConstraints([ + $one = (object) ['morph_type' => 'morph_type_1', 'foreign_key' => 'foreign_key_1'], + $two = (object) ['morph_type' => 'morph_type_1', 'foreign_key' => 'foreign_key_1'], + $three = (object) ['morph_type' => 'morph_type_2', 'foreign_key' => 'foreign_key_2'], + $four = (object) ['morph_type' => 'morph_type_2', 'foreign_key' => $stringish], + ]); + + $dictionary = $relation->getDictionary(); + + $this->assertEquals([ + 'morph_type_1' => [ + 'foreign_key_1' => [ + $one, + $two, + ], + ], + 'morph_type_2' => [ + 'foreign_key_2' => [ + $three, + $four, + ], + ], + ], $dictionary); + } + + public function testMorphToWithDefault() + { + $relation = $this->getRelation()->withDefault(); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + $newModel = new EloquentMorphToModelStub; + + $this->assertEquals($newModel, $relation->getResults()); + } + + public function testMorphToWithDynamicDefault() + { + $relation = $this->getRelation()->withDefault(function ($newModel) { + $newModel->username = 'taylor'; + }); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + $newModel = new EloquentMorphToModelStub; + $newModel->username = 'taylor'; + + $result = $relation->getResults(); + + $this->assertEquals($newModel, $result); + + $this->assertSame('taylor', $result->username); + } + + public function testMorphToWithArrayDefault() + { + $relation = $this->getRelation()->withDefault(['username' => 'taylor']); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + $newModel = new EloquentMorphToModelStub; + $newModel->username = 'taylor'; + + $result = $relation->getResults(); + + $this->assertEquals($newModel, $result); + + $this->assertSame('taylor', $result->username); + } + + public function testMorphToWithZeroMorphType() + { + $parent = $this->getMockBuilder(EloquentMorphToModelStub::class)->onlyMethods(['getAttributeFromArray', 'morphEagerTo', 'morphInstanceTo'])->getMock(); + $parent->method('getAttributeFromArray')->with('relation_type')->willReturn(0); + $parent->expects($this->once())->method('morphInstanceTo'); + $parent->expects($this->never())->method('morphEagerTo'); + + $parent->relation(); + } + + public function testMorphToWithEmptyStringMorphType() + { + $parent = $this->getMockBuilder(EloquentMorphToModelStub::class)->onlyMethods(['getAttributeFromArray', 'morphEagerTo', 'morphInstanceTo'])->getMock(); + $parent->method('getAttributeFromArray')->with('relation_type')->willReturn(''); + $parent->expects($this->once())->method('morphEagerTo'); + $parent->expects($this->never())->method('morphInstanceTo'); + + $parent->relation(); + } + + public function testMorphToWithSpecifiedClassDefault() + { + $parent = new EloquentMorphToModelStub; + $parent->relation_type = EloquentMorphToRelatedStub::class; + + $relation = $parent->relation()->withDefault(); + + $newModel = new EloquentMorphToRelatedStub; + + $result = $relation->getResults(); + + $this->assertEquals($newModel, $result); + } + + public function testAssociateMethodSetsForeignKeyAndTypeOnModel() + { + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->with('foreign_key')->andReturn('foreign.value'); + + $relation = $this->getRelationAssociate($parent); + + $associate = m::mock(Model::class); + $associate->shouldReceive('getAttribute')->andReturn(1); + $associate->shouldReceive('getMorphClass')->andReturn('Model'); + + $parent->shouldReceive('setAttribute')->once()->with('foreign_key', 1); + $parent->shouldReceive('setAttribute')->once()->with('morph_type', 'Model'); + $parent->shouldReceive('setRelation')->once()->with('relation', $associate); + + $relation->associate($associate); + } + + public function testAssociateMethodIgnoresNullValue() + { + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + + $relation = $this->getRelationAssociate($parent); + + $parent->shouldReceive('setAttribute')->once()->with('foreign_key', null); + $parent->shouldReceive('setAttribute')->once()->with('morph_type', null); + $parent->shouldReceive('setRelation')->once()->with('relation', null); + + $relation->associate(null); + } + + public function testDissociateMethodDeletesUnsetsKeyAndTypeOnModel() + { + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + + $relation = $this->getRelation($parent); + + $parent->shouldReceive('setAttribute')->once()->with('foreign_key', null); + $parent->shouldReceive('setAttribute')->once()->with('morph_type', null); + $parent->shouldReceive('setRelation')->once()->with('relation', null); + + $relation->dissociate(); + } + + public function testIsNotNull() + { + $relation = $this->getRelation(); + + $relation->getRelated()->shouldReceive('getTable')->never(); + $relation->getRelated()->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is(null)); + } + + public function testIsModel() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerParentKey() + { + $parent = m::mock(Model::class); + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return an integer + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('1'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerRelatedKey() + { + $parent = m::mock(Model::class); + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return a string + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('1'); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsModelWithIntegerKeys() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return an integer + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(1); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(1); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $this->assertTrue($relation->is($model)); + } + + public function testIsNotModelWithNullParentKey() + { + $parent = m::mock(Model::class); + + // when addConstraints is called we need to return the foreign value + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn('foreign.value'); + // when getParentKey is called we want to return null + + $parent->shouldReceive('getAttribute')->once()->with('foreign_key')->andReturn(null); + + $relation = $this->getRelation($parent); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithNullRelatedKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn(null); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherKey() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value.two'); + $model->shouldReceive('getTable')->never(); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherTable() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->never(); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('table.two'); + $model->shouldReceive('getConnectionName')->never(); + + $this->assertFalse($relation->is($model)); + } + + public function testIsNotModelWithAnotherConnection() + { + $relation = $this->getRelation(); + + $this->related->shouldReceive('getConnectionName')->once()->andReturn('relation'); + + $model = m::mock(Model::class); + $model->shouldReceive('getAttribute')->once()->with('id')->andReturn('foreign.value'); + $model->shouldReceive('getTable')->once()->andReturn('relation'); + $model->shouldReceive('getConnectionName')->once()->andReturn('relation.two'); + + $this->assertFalse($relation->is($model)); + } + + protected function getRelationAssociate($parent) + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('where')->with('relation.id', '=', 'foreign.value'); + $related = m::mock(Model::class); + $related->shouldReceive('getKey')->andReturn(1); + $related->shouldReceive('getTable')->andReturn('relation'); + $related->shouldReceive('qualifyColumn')->andReturnUsing(fn (string $column) => "relation.{$column}"); + $builder->shouldReceive('getModel')->andReturn($related); + + return new MorphTo($builder, $parent, 'foreign_key', 'id', 'morph_type', 'relation'); + } + + public function getRelation($parent = null, $builder = null) + { + $this->builder = $builder ?: m::mock(Builder::class); + $this->builder->shouldReceive('where')->with('relation.id', '=', 'foreign.value'); + $this->related = m::mock(Model::class); + $this->related->shouldReceive('getKeyName')->andReturn('id'); + $this->related->shouldReceive('getTable')->andReturn('relation'); + $this->related->shouldReceive('qualifyColumn')->andReturnUsing(fn (string $column) => "relation.{$column}"); + $this->builder->shouldReceive('getModel')->andReturn($this->related); + $parent = $parent ?: new EloquentMorphToModelStub; + + return m::mock(MorphTo::class.'[createModelByType]', [$this->builder, $parent, 'foreign_key', 'id', 'morph_type', 'relation']); + } +} + +class EloquentMorphToModelStub extends Model +{ + public $foreign_key = 'foreign.value'; + + public $table = 'eloquent_morph_to_model_stubs'; + + public function relation() + { + return $this->morphTo(); + } +} + +class EloquentMorphToRelatedStub extends Model +{ + public $table = 'eloquent_morph_to_related_stubs'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentPivotTest.php b/tests/Database/Laravel/DatabaseEloquentPivotTest.php new file mode 100755 index 000000000..522934d38 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentPivotTest.php @@ -0,0 +1,222 @@ +shouldReceive('getConnectionName')->twice()->andReturn('connection'); + $parent->setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $resolver->shouldReceive('connection')->andReturn($connection = m::mock(Connection::class)); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $connection->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); + $parent->getConnection()->getQueryGrammar()->shouldReceive('getDateFormat')->andReturn('Y-m-d H:i:s'); + $parent->setDateFormat('Y-m-d H:i:s'); + $pivot = Pivot::fromAttributes($parent, ['foo' => 'bar', 'created_at' => '2015-09-12'], 'table', true); + + $this->assertEquals(['foo' => 'bar', 'created_at' => '2015-09-12 00:00:00'], $pivot->getAttributes()); + $this->assertSame('connection', $pivot->getConnectionName()); + $this->assertSame('table', $pivot->getTable()); + $this->assertTrue($pivot->exists); + $this->assertSame($parent, $pivot->pivotParent); + } + + public function testMutatorsAreCalledFromConstructor() + { + $parent = m::mock(Model::class.'[getConnectionName]'); + $parent->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $pivot = DatabaseEloquentPivotTestMutatorStub::fromAttributes($parent, ['foo' => 'bar'], 'table', true); + + $this->assertTrue($pivot->getMutatorCalled()); + } + + public function testFromRawAttributesDoesNotDoubleMutate() + { + $parent = m::mock(Model::class.'[getConnectionName]'); + $parent->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $pivot = DatabaseEloquentPivotTestJsonCastStub::fromRawAttributes($parent, ['foo' => json_encode(['name' => 'Taylor'])], 'table', true); + + $this->assertEquals(['name' => 'Taylor'], $pivot->foo); + } + + public function testFromRawAttributesDoesNotMutate() + { + $parent = m::mock(Model::class.'[getConnectionName]'); + $parent->shouldReceive('getConnectionName')->once()->andReturn('connection'); + + $pivot = DatabaseEloquentPivotTestMutatorStub::fromRawAttributes($parent, ['foo' => 'bar'], 'table', true); + + $this->assertFalse($pivot->getMutatorCalled()); + } + + public function testPropertiesUnchangedAreNotDirty() + { + $parent = m::mock(Model::class.'[getConnectionName]'); + $parent->shouldReceive('getConnectionName')->once()->andReturn('connection'); + $pivot = Pivot::fromAttributes($parent, ['foo' => 'bar', 'shimy' => 'shake'], 'table', true); + + $this->assertEquals([], $pivot->getDirty()); + } + + public function testPropertiesChangedAreDirty() + { + $parent = m::mock(Model::class.'[getConnectionName]'); + $parent->shouldReceive('getConnectionName')->once()->andReturn('connection'); + $pivot = Pivot::fromAttributes($parent, ['foo' => 'bar', 'shimy' => 'shake'], 'table', true); + $pivot->shimy = 'changed'; + + $this->assertEquals(['shimy' => 'changed'], $pivot->getDirty()); + } + + public function testTimestampPropertyIsSetIfCreatedAtInAttributes() + { + $parent = m::mock(Model::class.'[getConnectionName,getDates]'); + $parent->shouldReceive('getConnectionName')->andReturn('connection'); + $parent->shouldReceive('getDates')->andReturn([]); + $pivot = DatabaseEloquentPivotTestDateStub::fromAttributes($parent, ['foo' => 'bar', 'created_at' => 'foo'], 'table'); + $this->assertTrue($pivot->timestamps); + + $pivot = DatabaseEloquentPivotTestDateStub::fromAttributes($parent, ['foo' => 'bar'], 'table'); + $this->assertFalse($pivot->timestamps); + } + + public function testTimestampPropertyIsTrueWhenCreatingFromRawAttributes() + { + $parent = m::mock(Model::class.'[getConnectionName,getDates]'); + $parent->shouldReceive('getConnectionName')->andReturn('connection'); + $pivot = Pivot::fromRawAttributes($parent, ['foo' => 'bar', 'created_at' => 'foo'], 'table'); + $this->assertTrue($pivot->timestamps); + } + + public function testKeysCanBeSetProperly() + { + $parent = m::mock(Model::class.'[getConnectionName]'); + $parent->shouldReceive('getConnectionName')->once()->andReturn('connection'); + $pivot = Pivot::fromAttributes($parent, ['foo' => 'bar'], 'table'); + $pivot->setPivotKeys('foreign', 'other'); + + $this->assertSame('foreign', $pivot->getForeignKey()); + $this->assertSame('other', $pivot->getOtherKey()); + } + + public function testDeleteMethodDeletesModelByKeys() + { + $pivot = $this->getMockBuilder(Pivot::class)->onlyMethods(['newQueryWithoutRelationships'])->getMock(); + $pivot->setPivotKeys('foreign', 'other'); + $pivot->foreign = 'foreign.value'; + $pivot->other = 'other.value'; + $query = m::mock(stdClass::class); + $query->shouldReceive('where')->once()->with(['foreign' => 'foreign.value', 'other' => 'other.value'])->andReturn($query); + $query->shouldReceive('delete')->once()->andReturn(true); + $pivot->expects($this->once())->method('newQueryWithoutRelationships')->willReturn($query); + + $rowsAffected = $pivot->delete(); + $this->assertEquals(1, $rowsAffected); + } + + public function testPivotModelTableNameIsSingular() + { + $pivot = new Pivot; + + $this->assertSame('pivot', $pivot->getTable()); + } + + public function testPivotModelWithParentReturnsParentsTimestampColumns() + { + $parent = m::mock(Model::class); + $parent->shouldReceive('getCreatedAtColumn')->andReturn('parent_created_at'); + $parent->shouldReceive('getUpdatedAtColumn')->andReturn('parent_updated_at'); + + $pivotWithParent = new Pivot; + $pivotWithParent->pivotParent = $parent; + + $this->assertSame('parent_created_at', $pivotWithParent->getCreatedAtColumn()); + $this->assertSame('parent_updated_at', $pivotWithParent->getUpdatedAtColumn()); + } + + public function testPivotModelWithoutParentReturnsModelTimestampColumns() + { + $model = new DummyModel; + + $pivotWithoutParent = new Pivot; + + $this->assertEquals($model->getCreatedAtColumn(), $pivotWithoutParent->getCreatedAtColumn()); + $this->assertEquals($model->getUpdatedAtColumn(), $pivotWithoutParent->getUpdatedAtColumn()); + } + + public function testWithoutRelations() + { + $original = new Pivot; + + $original->pivotParent = 'foo'; + $original->setRelation('bar', 'baz'); + + $this->assertSame('baz', $original->getRelation('bar')); + + $pivot = $original->withoutRelations(); + + $this->assertInstanceOf(Pivot::class, $pivot); + $this->assertNotSame($pivot, $original); + $this->assertSame('foo', $original->pivotParent); + $this->assertNull($pivot->pivotParent); + $this->assertTrue($original->relationLoaded('bar')); + $this->assertFalse($pivot->relationLoaded('bar')); + + $pivot = $original->unsetRelations(); + + $this->assertSame($pivot, $original); + $this->assertNull($pivot->pivotParent); + $this->assertFalse($pivot->relationLoaded('bar')); + } +} + +class DatabaseEloquentPivotTestDateStub extends Pivot +{ + public function getDates() + { + return []; + } +} + +class DatabaseEloquentPivotTestMutatorStub extends Pivot +{ + private $mutatorCalled = false; + + public function setFooAttribute($value) + { + $this->mutatorCalled = true; + + return $value; + } + + public function getMutatorCalled() + { + return $this->mutatorCalled; + } +} + +class DatabaseEloquentPivotTestJsonCastStub extends Pivot +{ + protected $casts = [ + 'foo' => 'json', + ]; +} + +class DummyModel extends Model +{ + // +} diff --git a/tests/Database/Laravel/DatabaseEloquentPolymorphicIntegrationTest.php b/tests/Database/Laravel/DatabaseEloquentPolymorphicIntegrationTest.php new file mode 100644 index 000000000..3e8456e65 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentPolymorphicIntegrationTest.php @@ -0,0 +1,296 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->timestamps(); + }); + + $this->schema()->create('posts', function ($table) { + $table->increments('id'); + $table->integer('user_id'); + $table->string('title'); + $table->text('body'); + $table->timestamps(); + }); + + $this->schema()->create('comments', function ($table) { + $table->increments('id'); + $table->integer('commentable_id'); + $table->string('commentable_type'); + $table->integer('user_id'); + $table->text('body'); + $table->timestamps(); + }); + + $this->schema()->create('likes', function ($table) { + $table->increments('id'); + $table->integer('likeable_id'); + $table->string('likeable_type'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('posts'); + $this->schema()->drop('comments'); + + parent::tearDown(); + } + + public function testItLoadsRelationshipsAutomatically() + { + $this->seedData(); + + $like = TestLikeWithSingleWith::first(); + + $this->assertTrue($like->relationLoaded('likeable')); + $this->assertEquals(TestComment::first(), $like->likeable); + } + + public function testItLoadsChainedRelationshipsAutomatically() + { + $this->seedData(); + + $like = TestLikeWithSingleWith::first(); + + $this->assertTrue($like->likeable->relationLoaded('commentable')); + $this->assertEquals(TestPost::first(), $like->likeable->commentable); + } + + public function testItLoadsNestedRelationshipsAutomatically() + { + $this->seedData(); + + $like = TestLikeWithNestedWith::first(); + + $this->assertTrue($like->relationLoaded('likeable')); + $this->assertTrue($like->likeable->relationLoaded('owner')); + + $this->assertEquals(TestUser::first(), $like->likeable->owner); + } + + public function testItLoadsNestedRelationshipsOnDemand() + { + $this->seedData(); + + $like = TestLike::with('likeable.owner')->first(); + + $this->assertTrue($like->relationLoaded('likeable')); + $this->assertTrue($like->likeable->relationLoaded('owner')); + + $this->assertEquals(TestUser::first(), $like->likeable->owner); + } + + public function testItLoadsNestedMorphRelationshipsOnDemand() + { + $this->seedData(); + + TestPost::first()->likes()->create([]); + + $likes = TestLike::with('likeable.owner')->get()->loadMorph('likeable', [ + TestComment::class => ['commentable'], + TestPost::class => 'comments', + ]); + + $this->assertTrue($likes[0]->relationLoaded('likeable')); + $this->assertTrue($likes[0]->likeable->relationLoaded('owner')); + $this->assertTrue($likes[0]->likeable->relationLoaded('commentable')); + + $this->assertTrue($likes[1]->relationLoaded('likeable')); + $this->assertTrue($likes[1]->likeable->relationLoaded('owner')); + $this->assertTrue($likes[1]->likeable->relationLoaded('comments')); + } + + public function testItLoadsNestedMorphRelationshipCountsOnDemand() + { + $this->seedData(); + + TestPost::first()->likes()->create([]); + TestComment::first()->likes()->create([]); + + $likes = TestLike::with('likeable.owner')->get()->loadMorphCount('likeable', [ + TestComment::class => ['likes'], + TestPost::class => 'comments', + ]); + + $this->assertTrue($likes[0]->relationLoaded('likeable')); + $this->assertTrue($likes[0]->likeable->relationLoaded('owner')); + $this->assertEquals(2, $likes[0]->likeable->likes_count); + + $this->assertTrue($likes[1]->relationLoaded('likeable')); + $this->assertTrue($likes[1]->likeable->relationLoaded('owner')); + $this->assertEquals(1, $likes[1]->likeable->comments_count); + + $this->assertTrue($likes[2]->relationLoaded('likeable')); + $this->assertTrue($likes[2]->likeable->relationLoaded('owner')); + $this->assertEquals(2, $likes[2]->likeable->likes_count); + } + + /** + * Helpers... + */ + protected function seedData() + { + $taylor = TestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + + $taylor->posts()->create(['title' => 'A title', 'body' => 'A body']) + ->comments()->create(['body' => 'A comment body', 'user_id' => 1]) + ->likes()->create([]); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class TestUser extends Eloquent +{ + protected $table = 'users'; + protected $guarded = []; + + public function posts() + { + return $this->hasMany(TestPost::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class TestPost extends Eloquent +{ + protected $table = 'posts'; + protected $guarded = []; + + public function comments() + { + return $this->morphMany(TestComment::class, 'commentable'); + } + + public function owner() + { + return $this->belongsTo(TestUser::class, 'user_id'); + } + + public function likes() + { + return $this->morphMany(TestLike::class, 'likeable'); + } +} + +/** + * Eloquent Models... + */ +class TestComment extends Eloquent +{ + protected $table = 'comments'; + protected $guarded = []; + protected $with = ['commentable']; + + public function owner() + { + return $this->belongsTo(TestUser::class, 'user_id'); + } + + public function commentable() + { + return $this->morphTo(); + } + + public function likes() + { + return $this->morphMany(TestLike::class, 'likeable'); + } +} + +class TestLike extends Eloquent +{ + protected $table = 'likes'; + protected $guarded = []; + + public function likeable() + { + return $this->morphTo(); + } +} + +class TestLikeWithSingleWith extends Eloquent +{ + protected $table = 'likes'; + protected $guarded = []; + protected $with = ['likeable']; + + public function likeable() + { + return $this->morphTo(); + } +} + +class TestLikeWithNestedWith extends Eloquent +{ + protected $table = 'likes'; + protected $guarded = []; + protected $with = ['likeable.owner']; + + public function likeable() + { + return $this->morphTo(); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentPolymorphicRelationsIntegrationTest.php b/tests/Database/Laravel/DatabaseEloquentPolymorphicRelationsIntegrationTest.php new file mode 100644 index 000000000..af747be77 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentPolymorphicRelationsIntegrationTest.php @@ -0,0 +1,193 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema('default')->create('posts', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema('default')->create('images', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema('default')->create('tags', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema('default')->create('taggables', function ($table) { + $table->integer('eloquent_many_to_many_polymorphic_test_tag_id'); + $table->integer('taggable_id'); + $table->string('taggable_type'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + foreach (['default'] as $connection) { + $this->schema($connection)->drop('posts'); + $this->schema($connection)->drop('images'); + $this->schema($connection)->drop('tags'); + $this->schema($connection)->drop('taggables'); + } + + Relation::morphMap([], false); + + parent::tearDown(); + } + + public function testCreation() + { + $post = EloquentManyToManyPolymorphicTestPost::create(); + $image = EloquentManyToManyPolymorphicTestImage::create(); + $tag = EloquentManyToManyPolymorphicTestTag::create(); + $tag2 = EloquentManyToManyPolymorphicTestTag::create(); + + $post->tags()->attach($tag->id); + $post->tags()->attach($tag2->id); + $image->tags()->attach($tag->id); + + $this->assertCount(2, $post->tags); + $this->assertCount(1, $image->tags); + $this->assertCount(1, $tag->posts); + $this->assertCount(1, $tag->images); + $this->assertCount(1, $tag2->posts); + $this->assertCount(0, $tag2->images); + } + + public function testEagerLoading() + { + $post = EloquentManyToManyPolymorphicTestPost::create(); + $tag = EloquentManyToManyPolymorphicTestTag::create(); + $post->tags()->attach($tag->id); + + $post = EloquentManyToManyPolymorphicTestPost::with('tags')->whereId(1)->first(); + $tag = EloquentManyToManyPolymorphicTestTag::with('posts')->whereId(1)->first(); + + $this->assertTrue($post->relationLoaded('tags')); + $this->assertTrue($tag->relationLoaded('posts')); + $this->assertEquals($tag->id, $post->tags->first()->id); + $this->assertEquals($post->id, $tag->posts->first()->id); + } + + public function testChunkById() + { + $post = EloquentManyToManyPolymorphicTestPost::create(); + $tag1 = EloquentManyToManyPolymorphicTestTag::create(); + $tag2 = EloquentManyToManyPolymorphicTestTag::create(); + $tag3 = EloquentManyToManyPolymorphicTestTag::create(); + $post->tags()->attach([$tag1->id, $tag2->id, $tag3->id]); + + $count = 0; + $iterations = 0; + $post->tags()->chunkById(2, function ($tags) use (&$iterations, &$count) { + $this->assertInstanceOf(EloquentManyToManyPolymorphicTestTag::class, $tags->first()); + $count += $tags->count(); + $iterations++; + }); + + $this->assertEquals(2, $iterations); + $this->assertEquals(3, $count); + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class EloquentManyToManyPolymorphicTestPost extends Eloquent +{ + protected $table = 'posts'; + protected $guarded = []; + + public function tags() + { + return $this->morphToMany(EloquentManyToManyPolymorphicTestTag::class, 'taggable'); + } +} + +class EloquentManyToManyPolymorphicTestImage extends Eloquent +{ + protected $table = 'images'; + protected $guarded = []; + + public function tags() + { + return $this->morphToMany(EloquentManyToManyPolymorphicTestTag::class, 'taggable'); + } +} + +class EloquentManyToManyPolymorphicTestTag extends Eloquent +{ + protected $table = 'tags'; + protected $guarded = []; + + public function posts() + { + return $this->morphedByMany(EloquentManyToManyPolymorphicTestPost::class, 'taggable'); + } + + public function images() + { + return $this->morphedByMany(EloquentManyToManyPolymorphicTestImage::class, 'taggable'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentRelationTest.php b/tests/Database/Laravel/DatabaseEloquentRelationTest.php new file mode 100755 index 000000000..4aabe3aa4 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentRelationTest.php @@ -0,0 +1,365 @@ +setRelation('test', $relation); + $parent->setRelation('foo', 'bar'); + $this->assertArrayNotHasKey('foo', $parent->toArray()); + } + + public function testUnsetExistingRelation() + { + $parent = new EloquentRelationResetModelStub; + $relation = new EloquentRelationResetModelStub; + $parent->setRelation('foo', $relation); + $parent->unsetRelation('foo'); + $this->assertFalse($parent->relationLoaded('foo')); + } + + public function testTouchMethodUpdatesRelatedTimestamps() + { + $builder = m::mock(Builder::class); + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $related = m::mock(EloquentNoTouchingModelStub::class)->makePartial(); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('whereNotNull'); + $builder->shouldReceive('where'); + $builder->shouldReceive('withoutGlobalScopes')->andReturn($builder); + $relation = new HasOne($builder, $parent, 'foreign_key', 'id'); + $related->shouldReceive('getTable')->andReturn('table'); + $related->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + $now = Carbon::now(); + $related->shouldReceive('freshTimestampString')->andReturn($now); + $builder->shouldReceive('update')->once()->with(['updated_at' => $now]); + + $relation->touch(); + } + + public function testCanDisableParentTouchingForAllModels() + { + /** @var \Illuminate\Tests\Database\EloquentNoTouchingModelStub $related */ + $related = m::mock(EloquentNoTouchingModelStub::class)->makePartial(); + $related->shouldReceive('getUpdatedAtColumn')->never(); + $related->shouldReceive('freshTimestampString')->never(); + + $this->assertFalse($related::isIgnoringTouch()); + + Model::withoutTouching(function () use ($related) { + $this->assertTrue($related::isIgnoringTouch()); + + $builder = m::mock(Builder::class); + $parent = m::mock(Model::class); + + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('whereNotNull'); + $builder->shouldReceive('where'); + $builder->shouldReceive('withoutGlobalScopes')->andReturn($builder); + $relation = new HasOne($builder, $parent, 'foreign_key', 'id'); + $builder->shouldReceive('update')->never(); + + $relation->touch(); + }); + + $this->assertFalse($related::isIgnoringTouch()); + } + + public function testCanDisableTouchingForSpecificModel() + { + $related = m::mock(EloquentNoTouchingModelStub::class)->makePartial(); + $related->shouldReceive('getUpdatedAtColumn')->never(); + $related->shouldReceive('freshTimestampString')->never(); + + $anotherRelated = m::mock(EloquentNoTouchingAnotherModelStub::class)->makePartial(); + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($anotherRelated::isIgnoringTouch()); + + EloquentNoTouchingModelStub::withoutTouching(function () use ($related, $anotherRelated) { + $this->assertTrue($related::isIgnoringTouch()); + $this->assertFalse($anotherRelated::isIgnoringTouch()); + + $builder = m::mock(Builder::class); + $parent = m::mock(Model::class); + + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('whereNotNull'); + $builder->shouldReceive('where'); + $builder->shouldReceive('withoutGlobalScopes')->andReturnSelf(); + $relation = new HasOne($builder, $parent, 'foreign_key', 'id'); + $builder->shouldReceive('update')->never(); + + $relation->touch(); + + $anotherBuilder = m::mock(Builder::class); + $anotherParent = m::mock(Model::class); + + $anotherParent->shouldReceive('getAttribute')->with('id')->andReturn(2); + $anotherBuilder->shouldReceive('getModel')->andReturn($anotherRelated); + $anotherBuilder->shouldReceive('whereNotNull'); + $anotherBuilder->shouldReceive('where'); + $anotherBuilder->shouldReceive('withoutGlobalScopes')->andReturnSelf(); + $anotherRelation = new HasOne($anotherBuilder, $anotherParent, 'foreign_key', 'id'); + $now = Carbon::now(); + $anotherRelated->shouldReceive('freshTimestampString')->andReturn($now); + $anotherBuilder->shouldReceive('update')->once()->with(['updated_at' => $now]); + + $anotherRelation->touch(); + }); + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($anotherRelated::isIgnoringTouch()); + } + + public function testParentModelIsNotTouchedWhenChildModelIsIgnored() + { + $related = m::mock(EloquentNoTouchingModelStub::class)->makePartial(); + $related->shouldReceive('getUpdatedAtColumn')->never(); + $related->shouldReceive('freshTimestampString')->never(); + + $relatedChild = m::mock(EloquentNoTouchingChildModelStub::class)->makePartial(); + $relatedChild->shouldReceive('getUpdatedAtColumn')->never(); + $relatedChild->shouldReceive('freshTimestampString')->never(); + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($relatedChild::isIgnoringTouch()); + + EloquentNoTouchingModelStub::withoutTouching(function () use ($related, $relatedChild) { + $this->assertTrue($related::isIgnoringTouch()); + $this->assertTrue($relatedChild::isIgnoringTouch()); + + $builder = m::mock(Builder::class); + $parent = m::mock(Model::class); + + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('whereNotNull'); + $builder->shouldReceive('where'); + $builder->shouldReceive('withoutGlobalScopes')->andReturnSelf(); + $relation = new HasOne($builder, $parent, 'foreign_key', 'id'); + $builder->shouldReceive('update')->never(); + + $relation->touch(); + + $anotherBuilder = m::mock(Builder::class); + $anotherParent = m::mock(Model::class); + + $anotherParent->shouldReceive('getAttribute')->with('id')->andReturn(2); + $anotherBuilder->shouldReceive('getModel')->andReturn($relatedChild); + $anotherBuilder->shouldReceive('whereNotNull'); + $anotherBuilder->shouldReceive('where'); + $anotherBuilder->shouldReceive('withoutGlobalScopes')->andReturnSelf(); + $anotherRelation = new HasOne($anotherBuilder, $anotherParent, 'foreign_key', 'id'); + $anotherBuilder->shouldReceive('update')->never(); + + $anotherRelation->touch(); + }); + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($relatedChild::isIgnoringTouch()); + } + + public function testIgnoredModelsStateIsResetWhenThereAreExceptions() + { + $related = m::mock(EloquentNoTouchingModelStub::class)->makePartial(); + $related->shouldReceive('getUpdatedAtColumn')->never(); + $related->shouldReceive('freshTimestampString')->never(); + + $relatedChild = m::mock(EloquentNoTouchingChildModelStub::class)->makePartial(); + $relatedChild->shouldReceive('getUpdatedAtColumn')->never(); + $relatedChild->shouldReceive('freshTimestampString')->never(); + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($relatedChild::isIgnoringTouch()); + + try { + EloquentNoTouchingModelStub::withoutTouching(function () use ($related, $relatedChild) { + $this->assertTrue($related::isIgnoringTouch()); + $this->assertTrue($relatedChild::isIgnoringTouch()); + + throw new Exception; + }); + + $this->fail('Exception was not thrown'); + } catch (Exception) { + // Does nothing. + } + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($relatedChild::isIgnoringTouch()); + } + + public function testSettingMorphMapWithNumericArrayUsesTheTableNames() + { + Relation::morphMap([EloquentRelationResetModelStub::class]); + + $this->assertEquals([ + 'reset' => EloquentRelationResetModelStub::class, + ], Relation::morphMap()); + + Relation::morphMap([], false); + } + + public function testSettingMorphMapWithNumericKeys() + { + Relation::morphMap([1 => 'App\User']); + + $this->assertEquals([ + 1 => 'App\User', + ], Relation::morphMap()); + + Relation::morphMap([], false); + } + + public function testGetMorphAlias() + { + Relation::morphMap(['user' => 'App\User']); + + $this->assertSame('user', Relation::getMorphAlias('App\User')); + $this->assertSame('Does\Not\Exist', Relation::getMorphAlias('Does\Not\Exist')); + } + + public function testWithoutRelations() + { + $original = new EloquentNoTouchingModelStub; + + $original->setRelation('foo', 'baz'); + + $this->assertSame('baz', $original->getRelation('foo')); + + $model = $original->withoutRelations(); + + $this->assertInstanceOf(EloquentNoTouchingModelStub::class, $model); + $this->assertTrue($original->relationLoaded('foo')); + $this->assertFalse($model->relationLoaded('foo')); + + $model = $original->unsetRelations(); + + $this->assertInstanceOf(EloquentNoTouchingModelStub::class, $model); + $this->assertFalse($original->relationLoaded('foo')); + $this->assertFalse($model->relationLoaded('foo')); + } + + public function testMacroable() + { + Relation::macro('foo', function () { + return 'foo'; + }); + + $model = new EloquentRelationResetModelStub; + $relation = new EloquentRelationStub($model->newQuery(), $model); + + $result = $relation->foo(); + $this->assertSame('foo', $result); + } + + public function testIsRelationIgnoresAttribute() + { + $model = new EloquentRelationAndAttributeModelStub; + + $this->assertTrue($model->isRelation('parent')); + $this->assertFalse($model->isRelation('field')); + } +} + +class EloquentRelationResetModelStub extends Model +{ + protected $table = 'reset'; + + // Override method call which would normally go through __call() + + public function getQuery() + { + return $this->newQuery()->getQuery(); + } +} + +class EloquentRelationStub extends Relation +{ + public function addConstraints() + { + // + } + + public function addEagerConstraints(array $models) + { + // + } + + public function initRelation(array $models, $relation) + { + // + } + + public function match(array $models, Collection $results, $relation) + { + // + } + + public function getResults() + { + // + } +} + +class EloquentNoTouchingModelStub extends Model +{ + protected $table = 'table'; + protected $attributes = [ + 'id' => 1, + ]; +} + +class EloquentNoTouchingChildModelStub extends EloquentNoTouchingModelStub +{ + // +} + +class EloquentNoTouchingAnotherModelStub extends Model +{ + protected $table = 'another_table'; + protected $attributes = [ + 'id' => 2, + ]; +} + +class EloquentRelationAndAttributeModelStub extends Model +{ + protected $table = 'one_more_table'; + + public function field(): Attribute + { + return new Attribute( + function ($value) { + return $value; + }, + function ($value) { + return $value; + }, + ); + } + + public function parent() + { + return $this->belongsTo(self::class); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentRelationshipsTest.php b/tests/Database/Laravel/DatabaseEloquentRelationshipsTest.php new file mode 100644 index 000000000..2e0c3a56f --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentRelationshipsTest.php @@ -0,0 +1,547 @@ +assertInstanceOf(HasOne::class, $post->attachment()); + $this->assertInstanceOf(BelongsTo::class, $post->author()); + $this->assertInstanceOf(HasMany::class, $post->comments()); + $this->assertInstanceOf(MorphOne::class, $post->owner()); + $this->assertInstanceOf(MorphMany::class, $post->likes()); + $this->assertInstanceOf(BelongsToMany::class, $post->viewers()); + $this->assertInstanceOf(HasManyThrough::class, $post->lovers()); + $this->assertInstanceOf(HasOneThrough::class, $post->contract()); + $this->assertInstanceOf(MorphToMany::class, $post->tags()); + $this->assertInstanceOf(MorphTo::class, $post->postable()); + } + + public function testOverriddenRelationships() + { + $post = new CustomPost; + + $this->assertInstanceOf(CustomHasOne::class, $post->attachment()); + $this->assertInstanceOf(CustomBelongsTo::class, $post->author()); + $this->assertInstanceOf(CustomHasMany::class, $post->comments()); + $this->assertInstanceOf(CustomMorphOne::class, $post->owner()); + $this->assertInstanceOf(CustomMorphMany::class, $post->likes()); + $this->assertInstanceOf(CustomBelongsToMany::class, $post->viewers()); + $this->assertInstanceOf(CustomHasManyThrough::class, $post->lovers()); + $this->assertInstanceOf(CustomHasOneThrough::class, $post->contract()); + $this->assertInstanceOf(CustomMorphToMany::class, $post->tags()); + $this->assertInstanceOf(CustomMorphTo::class, $post->postable()); + } + + public function testAlwaysUnsetBelongsToRelationWhenReceivedModelId() + { + // create users + $user1 = (new FakeRelationship)->forceFill(['id' => 1]); + $user2 = (new FakeRelationship)->forceFill(['id' => 2]); + + // sync user 1 using Model + $post = new Post; + $post->author()->associate($user1); + $post->syncOriginal(); + + // associate user 2 using Model + $post->author()->associate($user2); + $this->assertTrue($post->isDirty()); + $this->assertTrue($post->relationLoaded('author')); + $this->assertSame($user2, $post->author); + + // associate user 1 using model ID + $post->author()->associate($user1->id); + $this->assertTrue($post->isClean()); + + // we must unset relation even if attributes are clean + $this->assertFalse($post->relationLoaded('author')); + } + + public function testPendingHasThroughRelationship() + { + $fluent = (new FluentMechanic())->owner(); + $classic = (new ClassicMechanic())->owner(); + + $this->assertInstanceOf(HasOneThrough::class, $classic); + $this->assertInstanceOf(HasOneThrough::class, $fluent); + $this->assertSame('m_id', $classic->getLocalKeyName()); + $this->assertSame('m_id', $fluent->getLocalKeyName()); + $this->assertSame('c_id', $classic->getSecondLocalKeyName()); + $this->assertSame('c_id', $fluent->getSecondLocalKeyName()); + $this->assertSame('mechanic_id', $classic->getFirstKeyName()); + $this->assertSame('mechanic_id', $fluent->getFirstKeyName()); + $this->assertSame('car_id', $classic->getForeignKeyName()); + $this->assertSame('car_id', $fluent->getForeignKeyName()); + $this->assertSame('classic_mechanics.m_id', $classic->getQualifiedLocalKeyName()); + $this->assertSame('fluent_mechanics.m_id', $fluent->getQualifiedLocalKeyName()); + $this->assertSame('cars.mechanic_id', $fluent->getQualifiedFirstKeyName()); + $this->assertSame('cars.mechanic_id', $classic->getQualifiedFirstKeyName()); + + $fluent = (new FluentProject())->deployments(); + $classic = (new ClassicProject())->deployments(); + + $this->assertInstanceOf(HasManyThrough::class, $classic); + $this->assertInstanceOf(HasManyThrough::class, $fluent); + $this->assertSame('p_id', $classic->getLocalKeyName()); + $this->assertSame('p_id', $fluent->getLocalKeyName()); + $this->assertSame('e_id', $classic->getSecondLocalKeyName()); + $this->assertSame('e_id', $fluent->getSecondLocalKeyName()); + $this->assertSame('pro_id', $classic->getFirstKeyName()); + $this->assertSame('pro_id', $fluent->getFirstKeyName()); + $this->assertSame('env_id', $classic->getForeignKeyName()); + $this->assertSame('env_id', $fluent->getForeignKeyName()); + $this->assertSame('classic_projects.p_id', $classic->getQualifiedLocalKeyName()); + $this->assertSame('fluent_projects.p_id', $fluent->getQualifiedLocalKeyName()); + $this->assertSame('environments.pro_id', $fluent->getQualifiedFirstKeyName()); + $this->assertSame('environments.pro_id', $classic->getQualifiedFirstKeyName()); + + $fluent = (new FluentProject())->environmentData(); + $classic = (new ClassicProject())->environmentData(); + + $this->assertInstanceOf(HasManyThrough::class, $classic); + $this->assertInstanceOf(HasManyThrough::class, $fluent); + $this->assertSame('p_id', $classic->getLocalKeyName()); + $this->assertSame('p_id', $fluent->getLocalKeyName()); + $this->assertSame('e_id', $classic->getSecondLocalKeyName()); + $this->assertSame('e_id', $fluent->getSecondLocalKeyName()); + $this->assertSame('pro_id', $classic->getFirstKeyName()); + $this->assertSame('pro_id', $fluent->getFirstKeyName()); + $this->assertSame('env_id', $classic->getForeignKeyName()); + $this->assertSame('env_id', $fluent->getForeignKeyName()); + $this->assertSame('classic_projects.p_id', $classic->getQualifiedLocalKeyName()); + $this->assertSame('fluent_projects.p_id', $fluent->getQualifiedLocalKeyName()); + $this->assertSame('environments.pro_id', $fluent->getQualifiedFirstKeyName()); + $this->assertSame('environments.pro_id', $classic->getQualifiedFirstKeyName()); + } + + public function testStringyHasThroughApi() + { + $fluent = (new FluentMechanic())->owner(); + $stringy = (new class extends FluentMechanic + { + public function owner() + { + return $this->through('car')->has('owner'); + } + + public function getTable() + { + return 'stringy_mechanics'; + } + })->owner(); + + $this->assertInstanceOf(HasOneThrough::class, $fluent); + $this->assertInstanceOf(HasOneThrough::class, $stringy); + $this->assertSame('m_id', $fluent->getLocalKeyName()); + $this->assertSame('m_id', $stringy->getLocalKeyName()); + $this->assertSame('c_id', $fluent->getSecondLocalKeyName()); + $this->assertSame('c_id', $stringy->getSecondLocalKeyName()); + $this->assertSame('mechanic_id', $fluent->getFirstKeyName()); + $this->assertSame('mechanic_id', $stringy->getFirstKeyName()); + $this->assertSame('car_id', $fluent->getForeignKeyName()); + $this->assertSame('car_id', $stringy->getForeignKeyName()); + $this->assertSame('fluent_mechanics.m_id', $fluent->getQualifiedLocalKeyName()); + $this->assertSame('stringy_mechanics.m_id', $stringy->getQualifiedLocalKeyName()); + $this->assertSame('cars.mechanic_id', $stringy->getQualifiedFirstKeyName()); + $this->assertSame('cars.mechanic_id', $fluent->getQualifiedFirstKeyName()); + + $fluent = (new FluentProject())->deployments(); + $stringy = (new class extends FluentProject + { + public function deployments() + { + return $this->through('environments')->has('deployments'); + } + + public function getTable() + { + return 'stringy_projects'; + } + })->deployments(); + + $this->assertInstanceOf(HasManyThrough::class, $fluent); + $this->assertInstanceOf(HasManyThrough::class, $stringy); + $this->assertSame('p_id', $fluent->getLocalKeyName()); + $this->assertSame('p_id', $stringy->getLocalKeyName()); + $this->assertSame('e_id', $fluent->getSecondLocalKeyName()); + $this->assertSame('e_id', $stringy->getSecondLocalKeyName()); + $this->assertSame('pro_id', $fluent->getFirstKeyName()); + $this->assertSame('pro_id', $stringy->getFirstKeyName()); + $this->assertSame('env_id', $fluent->getForeignKeyName()); + $this->assertSame('env_id', $stringy->getForeignKeyName()); + $this->assertSame('fluent_projects.p_id', $fluent->getQualifiedLocalKeyName()); + $this->assertSame('stringy_projects.p_id', $stringy->getQualifiedLocalKeyName()); + $this->assertSame('environments.pro_id', $stringy->getQualifiedFirstKeyName()); + $this->assertSame('environments.pro_id', $fluent->getQualifiedFirstKeyName()); + } + + public function testHigherOrderHasThroughApi() + { + $fluent = (new FluentMechanic())->owner(); + $higher = (new class extends FluentMechanic + { + public function owner() + { + return $this->throughCar()->hasOwner(); + } + + public function getTable() + { + return 'higher_mechanics'; + } + })->owner(); + + $this->assertInstanceOf(HasOneThrough::class, $fluent); + $this->assertInstanceOf(HasOneThrough::class, $higher); + $this->assertSame('m_id', $fluent->getLocalKeyName()); + $this->assertSame('m_id', $higher->getLocalKeyName()); + $this->assertSame('c_id', $fluent->getSecondLocalKeyName()); + $this->assertSame('c_id', $higher->getSecondLocalKeyName()); + $this->assertSame('mechanic_id', $fluent->getFirstKeyName()); + $this->assertSame('mechanic_id', $higher->getFirstKeyName()); + $this->assertSame('car_id', $fluent->getForeignKeyName()); + $this->assertSame('car_id', $higher->getForeignKeyName()); + $this->assertSame('fluent_mechanics.m_id', $fluent->getQualifiedLocalKeyName()); + $this->assertSame('higher_mechanics.m_id', $higher->getQualifiedLocalKeyName()); + $this->assertSame('cars.mechanic_id', $higher->getQualifiedFirstKeyName()); + $this->assertSame('cars.mechanic_id', $fluent->getQualifiedFirstKeyName()); + + $fluent = (new FluentProject())->deployments(); + $higher = (new class extends FluentProject + { + public function deployments() + { + return $this->throughEnvironments()->hasDeployments(); + } + + public function getTable() + { + return 'higher_projects'; + } + })->deployments(); + + $this->assertInstanceOf(HasManyThrough::class, $fluent); + $this->assertInstanceOf(HasManyThrough::class, $higher); + $this->assertSame('p_id', $fluent->getLocalKeyName()); + $this->assertSame('p_id', $higher->getLocalKeyName()); + $this->assertSame('e_id', $fluent->getSecondLocalKeyName()); + $this->assertSame('e_id', $higher->getSecondLocalKeyName()); + $this->assertSame('pro_id', $fluent->getFirstKeyName()); + $this->assertSame('pro_id', $higher->getFirstKeyName()); + $this->assertSame('env_id', $fluent->getForeignKeyName()); + $this->assertSame('env_id', $higher->getForeignKeyName()); + $this->assertSame('fluent_projects.p_id', $fluent->getQualifiedLocalKeyName()); + $this->assertSame('higher_projects.p_id', $higher->getQualifiedLocalKeyName()); + $this->assertSame('environments.pro_id', $higher->getQualifiedFirstKeyName()); + $this->assertSame('environments.pro_id', $fluent->getQualifiedFirstKeyName()); + } +} + +class FakeRelationship extends Model +{ + // +} + +class Post extends Model +{ + public function attachment() + { + return $this->hasOne(FakeRelationship::class); + } + + public function author() + { + return $this->belongsTo(FakeRelationship::class); + } + + public function comments() + { + return $this->hasMany(FakeRelationship::class); + } + + public function likes() + { + return $this->morphMany(FakeRelationship::class, 'actionable'); + } + + public function owner() + { + return $this->morphOne(FakeRelationship::class, 'property'); + } + + public function viewers() + { + return $this->belongsToMany(FakeRelationship::class); + } + + public function lovers() + { + return $this->hasManyThrough(FakeRelationship::class, FakeRelationship::class); + } + + public function contract() + { + return $this->hasOneThrough(FakeRelationship::class, FakeRelationship::class); + } + + public function tags() + { + return $this->morphToMany(FakeRelationship::class, 'taggable'); + } + + public function postable() + { + return $this->morphTo(); + } +} + +class CustomPost extends Post +{ + protected function newBelongsTo(Builder $query, Model $child, $foreignKey, $ownerKey, $relation) + { + return new CustomBelongsTo($query, $child, $foreignKey, $ownerKey, $relation); + } + + protected function newHasMany(Builder $query, Model $parent, $foreignKey, $localKey) + { + return new CustomHasMany($query, $parent, $foreignKey, $localKey); + } + + protected function newHasOne(Builder $query, Model $parent, $foreignKey, $localKey) + { + return new CustomHasOne($query, $parent, $foreignKey, $localKey); + } + + protected function newMorphOne(Builder $query, Model $parent, $type, $id, $localKey) + { + return new CustomMorphOne($query, $parent, $type, $id, $localKey); + } + + protected function newMorphMany(Builder $query, Model $parent, $type, $id, $localKey) + { + return new CustomMorphMany($query, $parent, $type, $id, $localKey); + } + + protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, + $parentKey, $relatedKey, $relationName = null + ) { + return new CustomBelongsToMany($query, $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName); + } + + protected function newHasManyThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, + $secondKey, $localKey, $secondLocalKey + ) { + return new CustomHasManyThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + } + + protected function newHasOneThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, + $secondKey, $localKey, $secondLocalKey + ) { + return new CustomHasOneThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + } + + protected function newMorphToMany(Builder $query, Model $parent, $name, $table, $foreignPivotKey, + $relatedPivotKey, $parentKey, $relatedKey, $relationName = null, $inverse = false) + { + return new CustomMorphToMany($query, $parent, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, + $relationName, $inverse); + } + + protected function newMorphTo(Builder $query, Model $parent, $foreignKey, $ownerKey, $type, $relation) + { + return new CustomMorphTo($query, $parent, $foreignKey, $ownerKey, $type, $relation); + } +} + +class CustomHasOne extends HasOne +{ + // +} + +class CustomBelongsTo extends BelongsTo +{ + // +} + +class CustomHasMany extends HasMany +{ + // +} + +class CustomMorphOne extends MorphOne +{ + // +} + +class CustomMorphMany extends MorphMany +{ + // +} + +class CustomBelongsToMany extends BelongsToMany +{ + // +} + +class CustomHasManyThrough extends HasManyThrough +{ + // +} + +class CustomHasOneThrough extends HasOneThrough +{ + // +} + +class CustomMorphToMany extends MorphToMany +{ + // +} + +class CustomMorphTo extends MorphTo +{ + // +} + +class MockedConnectionModel extends Model +{ + public function getConnection() + { + $mock = m::mock(Connection::class); + $mock->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $grammar->shouldReceive('getBitwiseOperators')->andReturn([]); + $mock->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); + $mock->shouldReceive('getName')->andReturn('name'); + $mock->shouldReceive('query')->andReturnUsing(function () use ($mock, $grammar, $processor) { + return new BaseBuilder($mock, $grammar, $processor); + }); + + return $mock; + } +} + +class Car extends MockedConnectionModel +{ + public function owner() + { + return $this->hasOne(Owner::class, 'car_id', 'c_id'); + } +} + +class Owner extends MockedConnectionModel +{ + // +} + +class FluentMechanic extends MockedConnectionModel +{ + public function owner() + { + return $this->through($this->car()) + ->has(fn (Car $car) => $car->owner()); + } + + public function car() + { + return $this->hasOne(Car::class, 'mechanic_id', 'm_id'); + } +} + +class ClassicMechanic extends MockedConnectionModel +{ + public function owner() + { + return $this->hasOneThrough(Owner::class, Car::class, 'mechanic_id', 'car_id', 'm_id', 'c_id'); + } +} + +class ClassicProject extends MockedConnectionModel +{ + public function deployments() + { + return $this->hasManyThrough( + Deployment::class, + Environment::class, + 'pro_id', + 'env_id', + 'p_id', + 'e_id', + ); + } + + public function environmentData() + { + return $this->hasManyThrough( + Metadata::class, + Environment::class, + 'pro_id', + 'env_id', + 'p_id', + 'e_id', + ); + } +} + +class FluentProject extends MockedConnectionModel +{ + public function deployments() + { + return $this->through($this->environments())->has(fn (Environment $env) => $env->deployments()); + } + + public function environmentData() + { + return $this->through($this->environments())->has(fn (Environment $env) => $env->metadata()); + } + + public function environments() + { + return $this->hasMany(Environment::class, 'pro_id', 'p_id'); + } +} + +class Environment extends MockedConnectionModel +{ + public function deployments() + { + return $this->hasMany(Deployment::class, 'env_id', 'e_id'); + } + + public function metadata() + { + return $this->hasOne(MetaData::class, 'env_id', 'e_id'); + } +} + +class MetaData extends MockedConnectionModel +{ + // +} + +class Deployment extends MockedConnectionModel +{ + // +} diff --git a/tests/Database/Laravel/DatabaseEloquentResourceCollectionTest.php b/tests/Database/Laravel/DatabaseEloquentResourceCollectionTest.php new file mode 100644 index 000000000..c34317959 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentResourceCollectionTest.php @@ -0,0 +1,75 @@ +toResourceCollection(EloquentResourceCollectionTestResource::class); + + $this->assertInstanceOf(JsonResource::class, $resource); + } + + public function testItThrowsExceptionWhenResourceCannotBeFound() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Failed to find resource class for model [Illuminate\Tests\Database\Fixtures\Models\EloquentResourceCollectionTestModel].'); + + $collection = new Collection([ + new EloquentResourceCollectionTestModel(), + ]); + $collection->toResourceCollection(); + } + + public function testItCanGuessResourceWhenNotProvided() + { + $collection = new Collection([ + new EloquentResourceCollectionTestModel(), + ]); + + class_alias(EloquentResourceCollectionTestResource::class, 'Illuminate\Tests\Database\Fixtures\Http\Resources\EloquentResourceCollectionTestModelResource'); + + $resource = $collection->toResourceCollection(); + + $this->assertInstanceOf(JsonResource::class, $resource); + } + + public function testItCanTransformToResourceViaUseResourceAttribute() + { + $collection = new Collection([ + new EloquentResourceTestResourceModelWithUseResourceCollectionAttribute(), + ]); + + $resource = $collection->toResourceCollection(); + + $this->assertInstanceOf(EloquentResourceTestJsonResourceCollection::class, $resource); + } + + public function testItCanTransformToResourceViaUseResourceCollectionAttribute() + { + $collection = new Collection([ + new EloquentResourceTestResourceModelWithUseResourceAttribute(), + ]); + + $resource = $collection->toResourceCollection(); + + $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); + $this->assertInstanceOf(EloquentResourceTestJsonResource::class, $resource[0]); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentResourceModelTest.php b/tests/Database/Laravel/DatabaseEloquentResourceModelTest.php new file mode 100644 index 000000000..e518a4675 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentResourceModelTest.php @@ -0,0 +1,73 @@ +toResource(EloquentResourceTestJsonResource::class); + + $this->assertInstanceOf(EloquentResourceTestJsonResource::class, $resource); + $this->assertSame($model, $resource->resource); + } + + public function testItThrowsExceptionWhenResourceCannotBeFound() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Failed to find resource class for model [Illuminate\Tests\Database\Fixtures\Models\EloquentResourceTestResourceModel].'); + + $model = new EloquentResourceTestResourceModel(); + $model->toResource(); + } + + public function testItCanGuessResourceWhenNotProvided() + { + $model = new EloquentResourceTestResourceModelWithGuessableResource(); + + class_alias(EloquentResourceTestJsonResource::class, 'Illuminate\Tests\Database\Fixtures\Http\Resources\EloquentResourceTestResourceModelWithGuessableResourceResource'); + + $resource = $model->toResource(); + + $this->assertInstanceOf(EloquentResourceTestJsonResource::class, $resource); + $this->assertSame($model, $resource->resource); + } + + public function testItCanGuessResourceWhenNotProvidedWithNonResourceSuffix() + { + $model = new EloquentResourceTestResourceModelWithGuessableResource(); + + class_alias(EloquentResourceTestJsonResource::class, 'Illuminate\Tests\Database\Fixtures\Http\Resources\EloquentResourceTestResourceModelWithGuessableResource'); + + $resource = $model->toResource(); + + $this->assertInstanceOf(EloquentResourceTestJsonResource::class, $resource); + $this->assertSame($model, $resource->resource); + } + + public function testItCanGuessResourceName() + { + $model = new EloquentResourceTestResourceModel(); + $this->assertEquals([ + 'Illuminate\Tests\Database\Fixtures\Http\Resources\EloquentResourceTestResourceModelResource', + 'Illuminate\Tests\Database\Fixtures\Http\Resources\EloquentResourceTestResourceModel', + ], $model::guessResourceName()); + } + + public function testItCanTransformToResourceViaUseResourceAttribute() + { + $model = new EloquentResourceTestResourceModelWithUseResourceAttribute(); + + $resource = $model->toResource(); + + $this->assertInstanceOf(EloquentResourceTestJsonResource::class, $resource); + $this->assertSame($model, $resource->resource); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/Laravel/DatabaseEloquentSoftDeletesIntegrationTest.php new file mode 100644 index 000000000..2ab50d675 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -0,0 +1,1150 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->integer('user_id')->nullable(); // circular reference to parent User + $table->integer('group_id')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->schema()->create('posts', function ($table) { + $table->increments('id'); + $table->integer('user_id'); + $table->string('title'); + $table->integer('priority')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->schema()->create('comments', function ($table) { + $table->increments('id'); + $table->integer('owner_id')->nullable(); + $table->string('owner_type')->nullable(); + $table->integer('post_id'); + $table->string('body'); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->schema()->create('addresses', function ($table) { + $table->increments('id'); + $table->integer('user_id'); + $table->string('address'); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->schema()->create('groups', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + Carbon::setTestNow(null); + + $this->schema()->drop('users'); + $this->schema()->drop('posts'); + $this->schema()->drop('comments'); + + parent::tearDown(); + } + + /** + * Tests... + */ + public function testSoftDeletesAreNotRetrieved() + { + $this->createUsers(); + + $users = SoftDeletesTestUser::all(); + + $this->assertCount(1, $users); + $this->assertEquals(2, $users->first()->id); + $this->assertNull(SoftDeletesTestUser::find(1)); + } + + public function testSoftDeletesAreNotRetrievedFromBaseQuery() + { + $this->createUsers(); + + $query = SoftDeletesTestUser::query()->toBase(); + + $this->assertInstanceOf(Builder::class, $query); + $this->assertCount(1, $query->get()); + } + + public function testSoftDeletesAreNotRetrievedFromRelationshipBaseQuery() + { + [, $abigail] = $this->createUsers(); + + $abigail->posts()->create(['title' => 'Foo']); + $abigail->posts()->create(['title' => 'Bar'])->delete(); + + $query = $abigail->posts()->toBase(); + + $this->assertInstanceOf(Builder::class, $query); + $this->assertCount(1, $query->get()); + } + + public function testSoftDeletesAreNotRetrievedFromBuilderHelpers() + { + $this->createUsers(); + + $count = 0; + $query = SoftDeletesTestUser::query(); + $query->chunk(2, function ($user) use (&$count) { + $count += count($user); + }); + $this->assertEquals(1, $count); + + $query = SoftDeletesTestUser::query(); + $this->assertCount(1, $query->pluck('email')->all()); + + Paginator::currentPageResolver(function () { + return 1; + }); + + CursorPaginator::currentCursorResolver(function () { + return null; + }); + + $query = SoftDeletesTestUser::query(); + $this->assertCount(1, $query->paginate(2)->all()); + + $query = SoftDeletesTestUser::query(); + $this->assertCount(1, $query->simplePaginate(2)->all()); + + $query = SoftDeletesTestUser::query(); + $this->assertCount(1, $query->cursorPaginate(2)->all()); + + $this->assertEquals(0, SoftDeletesTestUser::where('email', 'taylorotwell@gmail.com')->increment('id')); + $this->assertEquals(0, SoftDeletesTestUser::where('email', 'taylorotwell@gmail.com')->decrement('id')); + } + + public function testWithTrashedReturnsAllRecords() + { + $this->createUsers(); + + $this->assertCount(2, SoftDeletesTestUser::withTrashed()->get()); + $this->assertInstanceOf(Eloquent::class, SoftDeletesTestUser::withTrashed()->find(1)); + } + + public function testWithTrashedAcceptsAnArgument() + { + $this->createUsers(); + + $this->assertCount(1, SoftDeletesTestUser::withTrashed(false)->get()); + $this->assertCount(2, SoftDeletesTestUser::withTrashed(true)->get()); + } + + public function testDeleteSetsDeletedColumn() + { + $this->createUsers(); + + $this->assertInstanceOf(Carbon::class, SoftDeletesTestUser::withTrashed()->find(1)->deleted_at); + $this->assertNull(SoftDeletesTestUser::find(2)->deleted_at); + } + + public function testForceDeleteActuallyDeletesRecords() + { + $this->createUsers(); + SoftDeletesTestUser::find(2)->forceDelete(); + + $users = SoftDeletesTestUser::withTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(1, $users->first()->id); + } + + public function testForceDeleteUpdateExistsProperty() + { + $this->createUsers(); + $user = SoftDeletesTestUser::find(2); + + $this->assertTrue($user->exists); + + $user->forceDelete(); + + $this->assertFalse($user->exists); + } + + public function testForceDeleteDoesntUpdateExistsPropertyIfFailed() + { + $user = new class() extends SoftDeletesTestUser + { + public $exists = true; + + public function newModelQuery() + { + return m::spy(parent::newModelQuery(), function (MockInterface $mock) { + $mock->shouldReceive('forceDelete')->andThrow(new Exception()); + }); + } + }; + + $this->assertTrue($user->exists); + + try { + $user->forceDelete(); + } catch (Exception) { + } + + $this->assertTrue($user->exists); + } + + public function testForceDestroyFullyDeletesRecord() + { + $this->createUsers(); + $deleted = SoftDeletesTestUser::forceDestroy(2); + + $this->assertSame(1, $deleted); + + $users = SoftDeletesTestUser::withTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(1, $users->first()->id); + $this->assertNull(SoftDeletesTestUser::find(2)); + } + + public function testForceDestroyDeletesAlreadyDeletedRecord() + { + $this->createUsers(); + $deleted = SoftDeletesTestUser::forceDestroy(1); + + $this->assertSame(1, $deleted); + + $users = SoftDeletesTestUser::withTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(2, $users->first()->id); + $this->assertNull(SoftDeletesTestUser::find(1)); + } + + public function testForceDestroyDeletesMultipleRecords() + { + $this->createUsers(); + $deleted = SoftDeletesTestUser::forceDestroy([1, 2]); + + $this->assertSame(2, $deleted); + + $this->assertTrue(SoftDeletesTestUser::withTrashed()->get()->isEmpty()); + } + + public function testForceDestroyDeletesRecordsFromCollection() + { + $this->createUsers(); + $deleted = SoftDeletesTestUser::forceDestroy(collect([1, 2])); + + $this->assertSame(2, $deleted); + + $this->assertTrue(SoftDeletesTestUser::withTrashed()->get()->isEmpty()); + } + + public function testForceDestroyDeletesRecordsFromEloquentCollection() + { + $this->createUsers(); + $deleted = SoftDeletesTestUser::forceDestroy(SoftDeletesTestUser::all()); + + $this->assertSame(1, $deleted); + + $users = SoftDeletesTestUser::withTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(1, $users->first()->id); + $this->assertNull(SoftDeletesTestUser::find(2)); + } + + public function testRestoreRestoresRecords() + { + $this->createUsers(); + $taylor = SoftDeletesTestUser::withTrashed()->find(1); + + $this->assertTrue($taylor->trashed()); + + $taylor->restore(); + + $users = SoftDeletesTestUser::all(); + + $this->assertCount(2, $users); + $this->assertNull($users->find(1)->deleted_at); + $this->assertNull($users->find(2)->deleted_at); + } + + public function testOnlyTrashedOnlyReturnsTrashedRecords() + { + $this->createUsers(); + + $users = SoftDeletesTestUser::onlyTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(1, $users->first()->id); + } + + public function testOnlyWithoutTrashedOnlyReturnsTrashedRecords() + { + $this->createUsers(); + + $users = SoftDeletesTestUser::withoutTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(2, $users->first()->id); + + $users = SoftDeletesTestUser::withTrashed()->withoutTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(2, $users->first()->id); + } + + public function testFirstOrNew() + { + $this->createUsers(); + + $result = SoftDeletesTestUser::firstOrNew(['email' => 'taylorotwell@gmail.com']); + $this->assertNull($result->id); + + $result = SoftDeletesTestUser::withTrashed()->firstOrNew(['email' => 'taylorotwell@gmail.com']); + $this->assertEquals(1, $result->id); + } + + public function testFindOrNew() + { + $this->createUsers(); + + $result = SoftDeletesTestUser::findOrNew(1); + $this->assertNull($result->id); + + $result = SoftDeletesTestUser::withTrashed()->findOrNew(1); + $this->assertEquals(1, $result->id); + } + + public function testFirstOrCreate() + { + $this->createUsers(); + + $result = SoftDeletesTestUser::withTrashed()->firstOrCreate(['email' => 'taylorotwell@gmail.com']); + $this->assertSame('taylorotwell@gmail.com', $result->email); + $this->assertCount(1, SoftDeletesTestUser::all()); + + $result = SoftDeletesTestUser::firstOrCreate(['email' => 'foo@bar.com']); + $this->assertSame('foo@bar.com', $result->email); + $this->assertCount(2, SoftDeletesTestUser::all()); + $this->assertCount(3, SoftDeletesTestUser::withTrashed()->get()); + } + + public function testCreateOrFirst() + { + $this->createUsers(); + + $result = SoftDeletesTestUser::withTrashed()->createOrFirst(['email' => 'taylorotwell@gmail.com']); + $this->assertSame('taylorotwell@gmail.com', $result->email); + $this->assertCount(1, SoftDeletesTestUser::all()); + + $result = SoftDeletesTestUser::createOrFirst(['email' => 'foo@bar.com']); + $this->assertSame('foo@bar.com', $result->email); + $this->assertCount(2, SoftDeletesTestUser::all()); + $this->assertCount(3, SoftDeletesTestUser::withTrashed()->get()); + } + + /** + * @throws \Exception + */ + public function testUpdateModelAfterSoftDeleting() + { + Carbon::setTestNow($now = Carbon::now()); + $this->createUsers(); + + /** @var \Illuminate\Tests\Database\SoftDeletesTestUser $userModel */ + $userModel = SoftDeletesTestUser::find(2); + $userModel->delete(); + $this->assertEquals($now->toDateTimeString(), $userModel->getOriginal('deleted_at')); + $this->assertNull(SoftDeletesTestUser::find(2)); + $this->assertEquals($userModel, SoftDeletesTestUser::withTrashed()->find(2)); + } + + /** + * @throws \Exception + */ + public function testRestoreAfterSoftDelete() + { + $this->createUsers(); + + /** @var \Illuminate\Tests\Database\SoftDeletesTestUser $userModel */ + $userModel = SoftDeletesTestUser::find(2); + $userModel->delete(); + $userModel->restore(); + + $this->assertEquals($userModel->id, SoftDeletesTestUser::find(2)->id); + } + + /** + * @throws \Exception + */ + public function testSoftDeleteAfterRestoring() + { + $this->createUsers(); + + /** @var \Illuminate\Tests\Database\SoftDeletesTestUser $userModel */ + $userModel = SoftDeletesTestUser::withTrashed()->find(1); + $userModel->restore(); + $this->assertEquals($userModel->deleted_at, SoftDeletesTestUser::find(1)->deleted_at); + $this->assertEquals($userModel->getOriginal('deleted_at'), SoftDeletesTestUser::find(1)->deleted_at); + $userModel->delete(); + $this->assertNull(SoftDeletesTestUser::find(1)); + $this->assertEquals($userModel->deleted_at, SoftDeletesTestUser::withTrashed()->find(1)->deleted_at); + $this->assertEquals($userModel->getOriginal('deleted_at'), SoftDeletesTestUser::withTrashed()->find(1)->deleted_at); + } + + public function testModifyingBeforeSoftDeletingAndRestoring() + { + $this->createUsers(); + + /** @var \Illuminate\Tests\Database\SoftDeletesTestUser $userModel */ + $userModel = SoftDeletesTestUser::find(2); + $userModel->email = 'foo@bar.com'; + $userModel->delete(); + $userModel->restore(); + + $this->assertEquals($userModel->id, SoftDeletesTestUser::find(2)->id); + $this->assertSame('foo@bar.com', SoftDeletesTestUser::find(2)->email); + } + + public function testUpdateOrCreate() + { + $this->createUsers(); + + $result = SoftDeletesTestUser::updateOrCreate(['email' => 'foo@bar.com'], ['email' => 'bar@baz.com']); + $this->assertSame('bar@baz.com', $result->email); + $this->assertCount(2, SoftDeletesTestUser::all()); + + $result = SoftDeletesTestUser::withTrashed()->updateOrCreate(['email' => 'taylorotwell@gmail.com'], ['email' => 'foo@bar.com']); + $this->assertSame('foo@bar.com', $result->email); + $this->assertCount(2, SoftDeletesTestUser::all()); + $this->assertCount(3, SoftDeletesTestUser::withTrashed()->get()); + } + + public function testHasOneRelationshipCanBeSoftDeleted() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $abigail->address()->create(['address' => 'Laravel avenue 43']); + + // delete on builder + $abigail->address()->delete(); + + $abigail = $abigail->fresh(); + + $this->assertNull($abigail->address); + $this->assertSame('Laravel avenue 43', $abigail->address()->withTrashed()->first()->address); + + // restore + $abigail->address()->withTrashed()->restore(); + + $abigail = $abigail->fresh(); + + $this->assertSame('Laravel avenue 43', $abigail->address->address); + + // delete on model + $abigail->address->delete(); + + $abigail = $abigail->fresh(); + + $this->assertNull($abigail->address); + $this->assertSame('Laravel avenue 43', $abigail->address()->withTrashed()->first()->address); + + // force delete + $abigail->address()->withTrashed()->forceDelete(); + + $abigail = $abigail->fresh(); + + $this->assertNull($abigail->address); + } + + public function testBelongsToRelationshipCanBeSoftDeleted() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $group = SoftDeletesTestGroup::create(['name' => 'admin']); + $abigail->group()->associate($group); + $abigail->save(); + + // delete on builder + $abigail->group()->delete(); + + $abigail = $abigail->fresh(); + + $this->assertNull($abigail->group); + $this->assertSame('admin', $abigail->group()->withTrashed()->first()->name); + + // restore + $abigail->group()->withTrashed()->restore(); + + $abigail = $abigail->fresh(); + + $this->assertSame('admin', $abigail->group->name); + + // delete on model + $abigail->group->delete(); + + $abigail = $abigail->fresh(); + + $this->assertNull($abigail->group); + $this->assertSame('admin', $abigail->group()->withTrashed()->first()->name); + + // force delete + $abigail->group()->withTrashed()->forceDelete(); + + $abigail = $abigail->fresh(); + + $this->assertNull($abigail->group()->withTrashed()->first()); + } + + public function testHasManyRelationshipCanBeSoftDeleted() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $abigail->posts()->create(['title' => 'First Title']); + $abigail->posts()->create(['title' => 'Second Title']); + + // delete on builder + $abigail->posts()->where('title', 'Second Title')->delete(); + + $abigail = $abigail->fresh(); + + $this->assertCount(1, $abigail->posts); + $this->assertSame('First Title', $abigail->posts->first()->title); + $this->assertCount(2, $abigail->posts()->withTrashed()->get()); + + // restore + $abigail->posts()->withTrashed()->restore(); + + $abigail = $abigail->fresh(); + + $this->assertCount(2, $abigail->posts); + + // force delete + $abigail->posts()->where('title', 'Second Title')->forceDelete(); + + $abigail = $abigail->fresh(); + + $this->assertCount(1, $abigail->posts); + $this->assertCount(1, $abigail->posts()->withTrashed()->get()); + } + + public function testRelationToSqlAppliesSoftDelete() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + + $this->assertSame( + 'select * from "posts" where "posts"."user_id" = ? and "posts"."user_id" is not null and "posts"."deleted_at" is null', + $abigail->posts()->toSql() + ); + } + + public function testRelationExistsAndDoesntExistHonorsSoftDelete() + { + $this->createUsers(); + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + + // 'exists' should return true before soft delete + $abigail->posts()->create(['title' => 'First Title']); + $this->assertTrue($abigail->posts()->exists()); + $this->assertFalse($abigail->posts()->doesntExist()); + + // 'exists' should return false after soft delete + $abigail->posts()->first()->delete(); + $this->assertFalse($abigail->posts()->exists()); + $this->assertTrue($abigail->posts()->doesntExist()); + + // 'exists' should return true after restore + $abigail->posts()->withTrashed()->restore(); + $this->assertTrue($abigail->posts()->exists()); + $this->assertFalse($abigail->posts()->doesntExist()); + + // 'exists' should return false after a force delete + $abigail->posts()->first()->forceDelete(); + $this->assertFalse($abigail->posts()->exists()); + $this->assertTrue($abigail->posts()->doesntExist()); + } + + public function testRelationCountHonorsSoftDelete() + { + $this->createUsers(); + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + + // check count before soft delete + $abigail->posts()->create(['title' => 'First Title']); + $abigail->posts()->create(['title' => 'Second Title']); + $this->assertEquals(2, $abigail->posts()->count()); + + // check count after soft delete + $abigail->posts()->where('title', 'Second Title')->delete(); + $this->assertEquals(1, $abigail->posts()->count()); + + // check count after restore + $abigail->posts()->withTrashed()->restore(); + $this->assertEquals(2, $abigail->posts()->count()); + + // check count after a force delete + $abigail->posts()->where('title', 'Second Title')->forceDelete(); + $this->assertEquals(1, $abigail->posts()->count()); + } + + public function testRelationAggregatesHonorsSoftDelete() + { + $this->createUsers(); + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + + // check aggregates before soft delete + $abigail->posts()->create(['title' => 'First Title', 'priority' => 2]); + $abigail->posts()->create(['title' => 'Second Title', 'priority' => 4]); + $abigail->posts()->create(['title' => 'Third Title', 'priority' => 6]); + $this->assertEquals(2, $abigail->posts()->min('priority')); + $this->assertEquals(6, $abigail->posts()->max('priority')); + $this->assertEquals(12, $abigail->posts()->sum('priority')); + $this->assertEquals(4, $abigail->posts()->avg('priority')); + + // check aggregates after soft delete + $abigail->posts()->where('title', 'First Title')->delete(); + $this->assertEquals(4, $abigail->posts()->min('priority')); + $this->assertEquals(6, $abigail->posts()->max('priority')); + $this->assertEquals(10, $abigail->posts()->sum('priority')); + $this->assertEquals(5, $abigail->posts()->avg('priority')); + + // check aggregates after restore + $abigail->posts()->withTrashed()->restore(); + $this->assertEquals(2, $abigail->posts()->min('priority')); + $this->assertEquals(6, $abigail->posts()->max('priority')); + $this->assertEquals(12, $abigail->posts()->sum('priority')); + $this->assertEquals(4, $abigail->posts()->avg('priority')); + + // check aggregates after a force delete + $abigail->posts()->where('title', 'Third Title')->forceDelete(); + $this->assertEquals(2, $abigail->posts()->min('priority')); + $this->assertEquals(4, $abigail->posts()->max('priority')); + $this->assertEquals(6, $abigail->posts()->sum('priority')); + $this->assertEquals(3, $abigail->posts()->avg('priority')); + } + + public function testSoftDeleteIsAppliedToNewQuery() + { + $query = (new SoftDeletesTestUser)->newQuery(); + $this->assertSame('select * from "users" where "users"."deleted_at" is null', $query->toSql()); + } + + public function testSecondLevelRelationshipCanBeSoftDeleted() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $post = $abigail->posts()->create(['title' => 'First Title']); + $post->comments()->create(['body' => 'Comment Body']); + + $abigail->posts()->first()->comments()->delete(); + + $abigail = $abigail->fresh(); + + $this->assertCount(0, $abigail->posts()->first()->comments); + $this->assertCount(1, $abigail->posts()->first()->comments()->withTrashed()->get()); + } + + public function testWhereHasWithDeletedRelationship() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $post = $abigail->posts()->create(['title' => 'First Title']); + + $users = SoftDeletesTestUser::where('email', 'taylorotwell@gmail.com')->has('posts')->get(); + $this->assertCount(0, $users); + + $users = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->has('posts')->get(); + $this->assertCount(1, $users); + + $users = SoftDeletesTestUser::where('email', 'doesnt@exist.com')->orHas('posts')->get(); + $this->assertCount(1, $users); + + $users = SoftDeletesTestUser::whereHas('posts', function ($query) { + $query->where('title', 'First Title'); + })->get(); + $this->assertCount(1, $users); + + $users = SoftDeletesTestUser::whereHas('posts', function ($query) { + $query->where('title', 'Another Title'); + })->get(); + $this->assertCount(0, $users); + + $users = SoftDeletesTestUser::where('email', 'doesnt@exist.com')->orWhereHas('posts', function ($query) { + $query->where('title', 'First Title'); + })->get(); + $this->assertCount(1, $users); + + // With Post Deleted... + + $post->delete(); + $users = SoftDeletesTestUser::has('posts')->get(); + $this->assertCount(0, $users); + } + + public function testWhereHasWithNestedDeletedRelationshipAndOnlyTrashedCondition() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $post = $abigail->posts()->create(['title' => 'First Title']); + $post->delete(); + + $users = SoftDeletesTestUser::has('posts')->get(); + $this->assertCount(0, $users); + + $users = SoftDeletesTestUser::whereHas('posts', function ($q) { + $q->onlyTrashed(); + })->get(); + $this->assertCount(1, $users); + + $users = SoftDeletesTestUser::whereHas('posts', function ($q) { + $q->withTrashed(); + })->get(); + $this->assertCount(1, $users); + } + + public function testWhereHasWithNestedDeletedRelationship() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $post = $abigail->posts()->create(['title' => 'First Title']); + $comment = $post->comments()->create(['body' => 'Comment Body']); + $comment->delete(); + + $users = SoftDeletesTestUser::has('posts.comments')->get(); + $this->assertCount(0, $users); + + $users = SoftDeletesTestUser::doesntHave('posts.comments')->get(); + $this->assertCount(1, $users); + } + + public function testWhereDoesntHaveWithNestedDeletedRelationship() + { + $this->createUsers(); + + $users = SoftDeletesTestUser::doesntHave('posts.comments')->get(); + $this->assertCount(1, $users); + } + + public function testWhereHasWithNestedDeletedRelationshipAndWithTrashedCondition() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUserWithTrashedPosts::where('email', 'abigailotwell@gmail.com')->first(); + $post = $abigail->posts()->create(['title' => 'First Title']); + $post->delete(); + + $users = SoftDeletesTestUserWithTrashedPosts::has('posts')->get(); + $this->assertCount(1, $users); + } + + public function testWithCountWithNestedDeletedRelationshipAndOnlyTrashedCondition() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $post1 = $abigail->posts()->create(['title' => 'First Title']); + $post1->delete(); + $abigail->posts()->create(['title' => 'Second Title']); + $abigail->posts()->create(['title' => 'Third Title']); + + $user = SoftDeletesTestUser::withCount('posts')->orderBy('postsCount', 'desc')->first(); + $this->assertEquals(2, $user->posts_count); + + $user = SoftDeletesTestUser::withCount(['posts' => function ($q) { + $q->onlyTrashed(); + }])->orderBy('postsCount', 'desc')->first(); + $this->assertEquals(1, $user->posts_count); + + $user = SoftDeletesTestUser::withCount(['posts' => function ($q) { + $q->withTrashed(); + }])->orderBy('postsCount', 'desc')->first(); + $this->assertEquals(3, $user->posts_count); + + $user = SoftDeletesTestUser::withCount(['posts' => function ($q) { + $q->withTrashed()->where('title', 'First Title'); + }])->orderBy('postsCount', 'desc')->first(); + $this->assertEquals(1, $user->posts_count); + + $user = SoftDeletesTestUser::withCount(['posts' => function ($q) { + $q->where('title', 'First Title'); + }])->orderBy('postsCount', 'desc')->first(); + $this->assertEquals(0, $user->posts_count); + } + + public function testOrWhereWithSoftDeleteConstraint() + { + $this->createUsers(); + + $users = SoftDeletesTestUser::where('email', 'taylorotwell@gmail.com')->orWhere('email', 'abigailotwell@gmail.com'); + $this->assertEquals(['abigailotwell@gmail.com'], $users->pluck('email')->all()); + } + + public function testMorphToWithTrashed() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $post1 = $abigail->posts()->create(['title' => 'First Title']); + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => SoftDeletesTestUser::class, + 'owner_id' => $abigail->id, + ]); + + $abigail->delete(); + + $comment = SoftDeletesTestCommentWithTrashed::with(['owner' => function ($q) { + $q->withoutGlobalScope(SoftDeletingScope::class); + }])->first(); + + $this->assertEquals($abigail->email, $comment->owner->email); + + $comment = SoftDeletesTestCommentWithTrashed::with(['owner' => function ($q) { + $q->withTrashed(); + }])->first(); + + $this->assertEquals($abigail->email, $comment->owner->email); + + $comment = TestCommentWithoutSoftDelete::with(['owner' => function ($q) { + $q->withTrashed(); + }])->first(); + + $this->assertEquals($abigail->email, $comment->owner->email); + } + + public function testMorphToWithBadMethodCall() + { + $this->expectException(BadMethodCallException::class); + + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $post1 = $abigail->posts()->create(['title' => 'First Title']); + + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => SoftDeletesTestUser::class, + 'owner_id' => $abigail->id, + ]); + + TestCommentWithoutSoftDelete::with(['owner' => function ($q) { + $q->thisMethodDoesNotExist(); + }])->first(); + } + + public function testMorphToWithConstraints() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $post1 = $abigail->posts()->create(['title' => 'First Title']); + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => SoftDeletesTestUser::class, + 'owner_id' => $abigail->id, + ]); + + $comment = SoftDeletesTestCommentWithTrashed::with(['owner' => function ($q) { + $q->where('email', 'taylorotwell@gmail.com'); + }])->first(); + + $this->assertNull($comment->owner); + } + + public function testMorphToWithoutConstraints() + { + $this->createUsers(); + + $abigail = SoftDeletesTestUser::where('email', 'abigailotwell@gmail.com')->first(); + $post1 = $abigail->posts()->create(['title' => 'First Title']); + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => SoftDeletesTestUser::class, + 'owner_id' => $abigail->id, + ]); + + $comment = SoftDeletesTestCommentWithTrashed::with('owner')->first(); + + $this->assertEquals($abigail->email, $comment->owner->email); + + $abigail->delete(); + $comment = SoftDeletesTestCommentWithTrashed::with('owner')->first(); + + $this->assertNull($comment->owner); + } + + public function testMorphToNonSoftDeletingModel() + { + $taylor = TestUserWithoutSoftDelete::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post1 = $taylor->posts()->create(['title' => 'First Title']); + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => TestUserWithoutSoftDelete::class, + 'owner_id' => $taylor->id, + ]); + + $comment = SoftDeletesTestCommentWithTrashed::with('owner')->first(); + + $this->assertEquals($taylor->email, $comment->owner->email); + + $taylor->delete(); + $comment = SoftDeletesTestCommentWithTrashed::with('owner')->first(); + + $this->assertNull($comment->owner); + } + + public function testSelfReferencingRelationshipWithSoftDeletes() + { + // https://github.com/laravel/framework/issues/42075 + [$taylor, $abigail] = $this->createUsers(); + + $this->assertCount(1, $abigail->self_referencing); + $this->assertTrue($abigail->self_referencing->first()->is($taylor)); + + $this->assertCount(0, $taylor->self_referencing); + $this->assertEquals(1, SoftDeletesTestUser::whereHas('self_referencing')->count()); + } + + /** + * Helpers... + * + * @return \Illuminate\Tests\Database\SoftDeletesTestUser[] + */ + protected function createUsers() + { + $taylor = SoftDeletesTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'user_id' => 2]); + $abigail = SoftDeletesTestUser::create(['id' => 2, 'email' => 'abigailotwell@gmail.com']); + + $taylor->delete(); + + return [$taylor, $abigail]; + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class TestUserWithoutSoftDelete extends Eloquent +{ + protected $table = 'users'; + protected $guarded = []; + + public function posts() + { + return $this->hasMany(SoftDeletesTestPost::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class SoftDeletesTestUser extends Eloquent +{ + use SoftDeletes; + + protected $table = 'users'; + protected $guarded = []; + + public function self_referencing() + { + return $this->hasMany(SoftDeletesTestUser::class, 'user_id')->onlyTrashed(); + } + + public function posts() + { + return $this->hasMany(SoftDeletesTestPost::class, 'user_id'); + } + + public function address() + { + return $this->hasOne(SoftDeletesTestAddress::class, 'user_id'); + } + + public function group() + { + return $this->belongsTo(SoftDeletesTestGroup::class, 'group_id'); + } +} + +class SoftDeletesTestUserWithTrashedPosts extends Eloquent +{ + use SoftDeletes; + + protected $table = 'users'; + protected $guarded = []; + + public function posts() + { + return $this->hasMany(SoftDeletesTestPost::class, 'user_id')->withTrashed(); + } +} + +/** + * Eloquent Models... + */ +class SoftDeletesTestPost extends Eloquent +{ + use SoftDeletes; + + protected $table = 'posts'; + protected $guarded = []; + + public function comments() + { + return $this->hasMany(SoftDeletesTestComment::class, 'post_id'); + } +} + +/** + * Eloquent Models... + */ +class TestCommentWithoutSoftDelete extends Eloquent +{ + protected $table = 'comments'; + protected $guarded = []; + + public function owner() + { + return $this->morphTo(); + } +} + +/** + * Eloquent Models... + */ +class SoftDeletesTestComment extends Eloquent +{ + use SoftDeletes; + + protected $table = 'comments'; + protected $guarded = []; + + public function owner() + { + return $this->morphTo(); + } +} + +class SoftDeletesTestCommentWithTrashed extends Eloquent +{ + use SoftDeletes; + + protected $table = 'comments'; + protected $guarded = []; + + public function owner() + { + return $this->morphTo(); + } +} + +/** + * Eloquent Models... + */ +class SoftDeletesTestAddress extends Eloquent +{ + use SoftDeletes; + + protected $table = 'addresses'; + protected $guarded = []; +} + +/** + * Eloquent Models... + */ +class SoftDeletesTestGroup extends Eloquent +{ + use SoftDeletes; + + protected $table = 'groups'; + protected $guarded = []; + + public function users() + { + $this->hasMany(SoftDeletesTestUser::class); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentStrictMorphsTest.php b/tests/Database/Laravel/DatabaseEloquentStrictMorphsTest.php new file mode 100644 index 000000000..84d4cd964 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentStrictMorphsTest.php @@ -0,0 +1,88 @@ +expectException(ClassMorphViolationException::class); + + $model = new TestModel; + + $model->getMorphClass(); + } + + public function testStrictModeDoesNotThrowExceptionWhenMorphMap() + { + $model = new TestModel; + + Relation::morphMap([ + 'test' => TestModel::class, + ]); + + $morphName = $model->getMorphClass(); + $this->assertSame('test', $morphName); + } + + public function testMapsCanBeEnforcedInOneMethod() + { + $model = new TestModel; + + Relation::requireMorphMap(false); + + Relation::enforceMorphMap([ + 'test' => TestModel::class, + ]); + + $morphName = $model->getMorphClass(); + $this->assertSame('test', $morphName); + } + + public function testMapIgnoreGenericPivotClass() + { + $pivotModel = new Pivot(); + + $pivotModel->getMorphClass(); + } + + public function testMapCanBeEnforcedToCustomPivotClass() + { + $this->expectException(ClassMorphViolationException::class); + + $pivotModel = new TestPivotModel(); + + $pivotModel->getMorphClass(); + } + + protected function tearDown(): void + { + Relation::morphMap([], false); + Relation::requireMorphMap(false); + + parent::tearDown(); + } +} + +class TestModel extends Model +{ +} + +class TestPivotModel extends Pivot +{ +} diff --git a/tests/Database/Laravel/DatabaseEloquentTimestampsTest.php b/tests/Database/Laravel/DatabaseEloquentTimestampsTest.php new file mode 100644 index 000000000..bc470ee9c --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentTimestampsTest.php @@ -0,0 +1,337 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->timestamps(); + }); + + $this->schema()->create('users_created_at', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->string('created_at'); + }); + + $this->schema()->create('users_updated_at', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->string('updated_at'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('users_created_at'); + $this->schema()->drop('users_updated_at'); + Carbon::setTestNow(null); + + parent::tearDown(); + } + + /** + * Tests... + */ + public function testUserWithCreatedAtAndUpdatedAt() + { + Carbon::setTestNow($now = Carbon::now()); + + $user = UserWithCreatedAndUpdated::create([ + 'email' => 'test@test.com', + ]); + + $this->assertEquals($now->toDateTimeString(), $user->created_at->toDateTimeString()); + $this->assertEquals($now->toDateTimeString(), $user->updated_at->toDateTimeString()); + } + + public function testUserWithCreatedAt() + { + Carbon::setTestNow($now = Carbon::now()); + + $user = UserWithCreated::create([ + 'email' => 'test@test.com', + ]); + + $this->assertEquals($now->toDateTimeString(), $user->created_at->toDateTimeString()); + } + + public function testUserWithUpdatedAt() + { + Carbon::setTestNow($now = Carbon::now()); + + $user = UserWithUpdated::create([ + 'email' => 'test@test.com', + ]); + + $this->assertEquals($now->toDateTimeString(), $user->updated_at->toDateTimeString()); + } + + public function testWithoutTimestamp() + { + Carbon::setTestNow($now = Carbon::now()->setYear(1995)->startOfYear()); + $user = UserWithCreatedAndUpdated::create(['email' => 'foo@example.com']); + Carbon::setTestNow(Carbon::now()->addHour()); + + $this->assertTrue($user->usesTimestamps()); + + $user->withoutTimestamps(function () use ($user) { + $this->assertFalse($user->usesTimestamps()); + + $user->withoutTimestamps(function () use ($user) { + $this->assertFalse($user->usesTimestamps()); + }); + + $this->assertFalse($user->usesTimestamps()); + $user->update([ + 'email' => 'bar@example.com', + ]); + }); + + $this->assertTrue($user->usesTimestamps()); + $this->assertTrue($now->equalTo($user->updated_at)); + $this->assertSame('bar@example.com', $user->email); + } + + public function testWithoutTimestampWhenAlreadyIgnoringTimestamps() + { + Carbon::setTestNow($now = Carbon::now()->setYear(1995)->startOfYear()); + $user = UserWithCreatedAndUpdated::create(['email' => 'foo@example.com']); + Carbon::setTestNow(Carbon::now()->addHour()); + + $user->timestamps = false; + + $this->assertFalse($user->usesTimestamps()); + + $user->withoutTimestamps(function () use ($user) { + $this->assertFalse($user->usesTimestamps()); + $user->update([ + 'email' => 'bar@example.com', + ]); + }); + + $this->assertFalse($user->usesTimestamps()); + $this->assertTrue($now->equalTo($user->updated_at)); + $this->assertSame('bar@example.com', $user->email); + } + + public function testWithoutTimestampRestoresWhenClosureThrowsException() + { + $user = UserWithCreatedAndUpdated::create(['email' => 'foo@example.com']); + + $user->timestamps = true; + + try { + $user->withoutTimestamps(function () use ($user) { + $this->assertFalse($user->usesTimestamps()); + throw new RuntimeException(); + }); + $this->fail(); + } catch (RuntimeException) { + // + } + + $this->assertTrue($user->timestamps); + } + + public function testWithoutTimestampsRespectsClasses() + { + $a = new UserWithCreatedAndUpdated(); + $b = new UserWithCreatedAndUpdated(); + $z = new UserWithUpdated(); + + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + + Eloquent::withoutTimestamps(function () use ($a, $b, $z) { + $this->assertFalse($a->usesTimestamps()); + $this->assertFalse($b->usesTimestamps()); + $this->assertFalse($z->usesTimestamps()); + $this->assertTrue(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertTrue(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + }); + + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + + UserWithCreatedAndUpdated::withoutTimestamps(function () use ($a, $b, $z) { + $this->assertFalse($a->usesTimestamps()); + $this->assertFalse($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertTrue(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + }); + + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + + UserWithUpdated::withoutTimestamps(function () use ($a, $b, $z) { + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertFalse($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertTrue(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + }); + + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + + Eloquent::withoutTimestampsOn([], function () use ($a, $b, $z) { + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + }); + + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + + Eloquent::withoutTimestampsOn([UserWithCreatedAndUpdated::class], function () use ($a, $b, $z) { + $this->assertFalse($a->usesTimestamps()); + $this->assertFalse($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertTrue(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + }); + + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + + Eloquent::withoutTimestampsOn([UserWithUpdated::class], function () use ($a, $b, $z) { + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertFalse($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertTrue(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + }); + + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + + Eloquent::withoutTimestampsOn([UserWithCreatedAndUpdated::class, UserWithUpdated::class], function () use ($a, $b, $z) { + $this->assertFalse($a->usesTimestamps()); + $this->assertFalse($b->usesTimestamps()); + $this->assertFalse($z->usesTimestamps()); + $this->assertTrue(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertTrue(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + }); + + $this->assertTrue($a->usesTimestamps()); + $this->assertTrue($b->usesTimestamps()); + $this->assertTrue($z->usesTimestamps()); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithCreatedAndUpdated::class)); + $this->assertFalse(Eloquent::isIgnoringTimestamps(UserWithUpdated::class)); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class UserWithCreatedAndUpdated extends Eloquent +{ + protected $table = 'users'; + + protected $guarded = []; +} + +class UserWithCreated extends Eloquent +{ + public const UPDATED_AT = null; + + protected $table = 'users_created_at'; + + protected $guarded = []; + + protected $dateFormat = 'U'; +} + +class UserWithUpdated extends Eloquent +{ + public const CREATED_AT = null; + + protected $table = 'users_updated_at'; + + protected $guarded = []; + + protected $dateFormat = 'U'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentWithAttributesPendingTest.php b/tests/Database/Laravel/DatabaseEloquentWithAttributesPendingTest.php new file mode 100644 index 000000000..881e11b97 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentWithAttributesPendingTest.php @@ -0,0 +1,155 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + } + + protected function tearDown(): void + { + $this->schema()->dropIfExists((new PendingAttributesModel)->getTable()); + + parent::tearDown(); + } + + public function testAddsAttributes(): void + { + $key = 'a key'; + $value = 'the value'; + + $query = PendingAttributesModel::query() + ->withAttributes([$key => $value], asConditions: false); + + $model = $query->make(); + + $this->assertSame($value, $model->$key); + } + + public function testDoesNotAddWheres(): void + { + $key = 'a key'; + $value = 'the value'; + + $query = PendingAttributesModel::query() + ->withAttributes([$key => $value], asConditions: false); + + $wheres = $query->toBase()->wheres; + + // Ensure no wheres exist + $this->assertEmpty($wheres); + } + + public function testAddsWithCasts(): void + { + $query = PendingAttributesModel::query() + ->withAttributes([ + 'is_admin' => 1, + 'first_name' => 'FIRST', + 'last_name' => 'LAST', + 'type' => PendingAttributesEnum::internal, + ], asConditions: false); + + $model = $query->make(); + + $this->assertSame(true, $model->is_admin); + $this->assertSame('First', $model->first_name); + $this->assertSame('Last', $model->last_name); + $this->assertSame(PendingAttributesEnum::internal, $model->type); + + $this->assertEqualsCanonicalizing([ + 'is_admin' => 1, + 'first_name' => 'first', + 'last_name' => 'last', + 'type' => 'int', + ], $model->getAttributes()); + } + + public function testAddsWithCastsViaDb(): void + { + $this->bootTable(); + + $query = PendingAttributesModel::query() + ->withAttributes([ + 'is_admin' => 1, + 'first_name' => 'FIRST', + 'last_name' => 'LAST', + 'type' => PendingAttributesEnum::internal, + ], asConditions: false); + + $query->create(); + + $model = PendingAttributesModel::first(); + + $this->assertSame(true, $model->is_admin); + $this->assertSame('First', $model->first_name); + $this->assertSame('Last', $model->last_name); + $this->assertSame(PendingAttributesEnum::internal, $model->type); + } + + protected function bootTable(): void + { + $this->schema()->create((new PendingAttributesModel)->getTable(), function ($table) { + $table->id(); + $table->boolean('is_admin'); + $table->string('first_name'); + $table->string('last_name'); + $table->string('type'); + $table->timestamps(); + }); + } + + protected function schema(): Builder + { + return PendingAttributesModel::getConnectionResolver()->connection()->getSchemaBuilder(); + } +} + +class PendingAttributesModel extends Model +{ + protected $guarded = []; + + protected $casts = [ + 'is_admin' => 'boolean', + 'type' => PendingAttributesEnum::class, + ]; + + public function setFirstNameAttribute(string $value): void + { + $this->attributes['first_name'] = strtolower($value); + } + + public function getFirstNameAttribute(?string $value): string + { + return ucfirst($value); + } + + protected function lastName(): Attribute + { + return Attribute::make( + get: fn (string $value) => ucfirst($value), + set: fn (string $value) => strtolower($value), + ); + } +} + +enum PendingAttributesEnum: string +{ + case internal = 'int'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentWithAttributesTest.php b/tests/Database/Laravel/DatabaseEloquentWithAttributesTest.php new file mode 100755 index 000000000..be4a6bc2e --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentWithAttributesTest.php @@ -0,0 +1,160 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + } + + protected function tearDown(): void + { + $this->schema()->dropIfExists((new WithAttributesModel)->getTable()); + + parent::tearDown(); + } + + public function testAddsAttributes(): void + { + $key = 'a key'; + $value = 'the value'; + + $query = WithAttributesModel::query() + ->withAttributes([$key => $value]); + + $model = $query->make(); + + $this->assertSame($value, $model->$key); + } + + public function testAddsWheres(): void + { + $key = 'a key'; + $value = 'the value'; + + $query = WithAttributesModel::query() + ->withAttributes([$key => $value]); + + $wheres = $query->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_models.'.$key, + 'operator' => '=', + 'value' => $value, + 'boolean' => 'and', + ], $wheres); + } + + public function testAddsWithCasts(): void + { + $query = WithAttributesModel::query() + ->withAttributes([ + 'is_admin' => 1, + 'first_name' => 'FIRST', + 'last_name' => 'LAST', + 'type' => WithAttributesEnum::internal, + ]); + + $model = $query->make(); + + $this->assertSame(true, $model->is_admin); + $this->assertSame('First', $model->first_name); + $this->assertSame('Last', $model->last_name); + $this->assertSame(WithAttributesEnum::internal, $model->type); + + $this->assertEqualsCanonicalizing([ + 'is_admin' => 1, + 'first_name' => 'first', + 'last_name' => 'last', + 'type' => 'int', + ], $model->getAttributes()); + } + + public function testAddsWithCastsViaDb(): void + { + $this->bootTable(); + + $query = WithAttributesModel::query() + ->withAttributes([ + 'is_admin' => 1, + 'first_name' => 'FIRST', + 'last_name' => 'LAST', + 'type' => WithAttributesEnum::internal, + ]); + + $query->create(); + + $model = WithAttributesModel::first(); + + $this->assertSame(true, $model->is_admin); + $this->assertSame('First', $model->first_name); + $this->assertSame('Last', $model->last_name); + $this->assertSame(WithAttributesEnum::internal, $model->type); + } + + protected function bootTable(): void + { + $this->schema()->create((new WithAttributesModel)->getTable(), function ($table) { + $table->id(); + $table->boolean('is_admin'); + $table->string('first_name'); + $table->string('last_name'); + $table->string('type'); + $table->timestamps(); + }); + } + + protected function schema(): Builder + { + return WithAttributesModel::getConnectionResolver()->connection()->getSchemaBuilder(); + } +} + +class WithAttributesModel extends Model +{ + protected $guarded = []; + + protected $casts = [ + 'is_admin' => 'boolean', + 'type' => WithAttributesEnum::class, + ]; + + public function setFirstNameAttribute(string $value): void + { + $this->attributes['first_name'] = strtolower($value); + } + + public function getFirstNameAttribute(?string $value): string + { + return ucfirst($value); + } + + protected function lastName(): Attribute + { + return Attribute::make( + get: fn (string $value) => ucfirst($value), + set: fn (string $value) => strtolower($value), + ); + } +} + +enum WithAttributesEnum: string +{ + case internal = 'int'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentWithCastsTest.php b/tests/Database/Laravel/DatabaseEloquentWithCastsTest.php new file mode 100644 index 000000000..fa5dc1d72 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentWithCastsTest.php @@ -0,0 +1,133 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('times', function ($table) { + $table->increments('id'); + $table->time('time'); + $table->timestamps(); + }); + + $this->schema()->create('unique_times', function ($table) { + $table->increments('id'); + $table->time('time')->unique(); + $table->timestamps(); + }); + } + + public function testWithFirstOrNew() + { + $time1 = Time::query()->withCasts(['time' => 'string']) + ->firstOrNew(['time' => '07:30']); + + Time::query()->insert(['time' => '07:30']); + + $time2 = Time::query()->withCasts(['time' => 'string']) + ->firstOrNew(['time' => '07:30']); + + $this->assertSame('07:30', $time1->time); + $this->assertSame($time1->time, $time2->time); + } + + public function testWithFirstOrCreate() + { + $time1 = Time::query()->withCasts(['time' => 'string']) + ->firstOrCreate(['time' => '07:30']); + + $time2 = Time::query()->withCasts(['time' => 'string']) + ->firstOrCreate(['time' => '07:30']); + + $this->assertSame($time1->id, $time2->id); + } + + public function testWithCreateOrFirst() + { + $time1 = UniqueTime::query()->withCasts(['time' => 'string']) + ->createOrFirst(['time' => '07:30']); + + $time2 = UniqueTime::query()->withCasts(['time' => 'string']) + ->createOrFirst(['time' => '07:30']); + + $this->assertSame($time1->id, $time2->id); + } + + public function testThrowsExceptionIfCastableAttributeWasNotRetrievedAndPreventMissingAttributesIsEnabled() + { + Time::create(['time' => now()]); + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(); + + $this->expectException(MissingAttributeException::class); + try { + $time = Time::query()->select('id')->first(); + $this->assertNull($time->time); + } finally { + Model::preventAccessingMissingAttributes($originalMode); + } + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +class Time extends Eloquent +{ + protected $guarded = []; + + protected $casts = [ + 'time' => 'datetime', + ]; +} + +class UniqueTime extends Eloquent +{ + protected $guarded = []; + + protected $casts = [ + 'time' => 'datetime', + ]; +} diff --git a/tests/Database/Laravel/DatabaseIntegrationTest.php b/tests/Database/Laravel/DatabaseIntegrationTest.php new file mode 100644 index 000000000..594e43190 --- /dev/null +++ b/tests/Database/Laravel/DatabaseIntegrationTest.php @@ -0,0 +1,38 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->setAsGlobal(); + $db->setEventDispatcher(new Dispatcher); + } + + public function testQueryExecutedToRawSql(): void + { + $connection = DB::connection(); + + $connection->listen(function (QueryExecuted $query) use (&$queryExecuted): void { + $queryExecuted = $query; + }); + + $connection->select('select ?', [true]); + + $this->assertInstanceOf(QueryExecuted::class, $queryExecuted); + $this->assertSame('select ?', $queryExecuted->sql); + $this->assertSame([true], $queryExecuted->bindings); + $this->assertSame('select 1', $queryExecuted->toRawSql()); + } +} diff --git a/tests/Database/Laravel/DatabaseMariaDbBuilderTest.php b/tests/Database/Laravel/DatabaseMariaDbBuilderTest.php new file mode 100644 index 000000000..fce93c34c --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbBuilderTest.php @@ -0,0 +1,43 @@ +shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8mb4'); + $connection->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8mb4_unicode_ci'); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'create database `my_temporary_database` default character set `utf8mb4` default collate `utf8mb4_unicode_ci`' + )->andReturn(true); + + $builder = new MariaDbBuilder($connection); + $builder->createDatabase('my_temporary_database'); + } + + public function testDropDatabaseIfExists() + { + $connection = m::mock(Connection::class); + $grammar = new MariaDbGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'drop database if exists `my_database_a`' + )->andReturn(true); + + $builder = new MariaDbBuilder($connection); + + $builder->dropDatabaseIfExists('my_database_a'); + } +} diff --git a/tests/Database/Laravel/DatabaseMariaDbProcessorTest.php b/tests/Database/Laravel/DatabaseMariaDbProcessorTest.php new file mode 100644 index 000000000..cff7981e8 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbProcessorTest.php @@ -0,0 +1,32 @@ + 'id', 'type_name' => 'bigint', 'type' => 'bigint', 'collation' => 'collate', 'nullable' => 'YES', 'default' => '', 'extra' => 'auto_increment', 'comment' => 'bar', 'expression' => null], + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar(100)', 'collation' => 'collate', 'nullable' => 'NO', 'default' => 'foo', 'extra' => '', 'comment' => '', 'expression' => null], + ['name' => 'email', 'type_name' => 'varchar', 'type' => 'varchar(100)', 'collation' => 'collate', 'nullable' => 'YES', 'default' => 'NULL', 'extra' => 'on update CURRENT_TIMESTAMP', 'comment' => 'NULL', 'expression' => null], + ]; + $expected = [ + ['name' => 'id', 'type_name' => 'bigint', 'type' => 'bigint', 'collation' => 'collate', 'nullable' => true, 'default' => '', 'auto_increment' => true, 'comment' => 'bar', 'generation' => null], + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar(100)', 'collation' => 'collate', 'nullable' => false, 'default' => 'foo', 'auto_increment' => false, 'comment' => '', 'generation' => null], + ['name' => 'email', 'type_name' => 'varchar', 'type' => 'varchar(100)', 'collation' => 'collate', 'nullable' => true, 'default' => 'NULL', 'auto_increment' => false, 'comment' => 'NULL', 'generation' => null], + ]; + $this->assertEquals($expected, $processor->processColumns($listing)); + + // convert listing to objects to simulate PDO::FETCH_CLASS + foreach ($listing as &$row) { + $row = (object) $row; + } + + $this->assertEquals($expected, $processor->processColumns($listing)); + } +} diff --git a/tests/Database/Laravel/DatabaseMariaDbQueryGrammarTest.php b/tests/Database/Laravel/DatabaseMariaDbQueryGrammarTest.php new file mode 100755 index 000000000..359f645e8 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbQueryGrammarTest.php @@ -0,0 +1,25 @@ +shouldReceive('escape')->with('foo', false)->andReturn("'foo'"); + $grammar = new MariaDbGrammar($connection); + + $query = $grammar->substituteBindingsIntoRawSql( + 'select * from "users" where \'Hello\\\'World?\' IS NOT NULL AND "email" = ?', + ['foo'], + ); + + $this->assertSame('select * from "users" where \'Hello\\\'World?\' IS NOT NULL AND "email" = \'foo\'', $query); + } +} diff --git a/tests/Database/Laravel/DatabaseMariaDbSchemaBuilderTest.php b/tests/Database/Laravel/DatabaseMariaDbSchemaBuilderTest.php new file mode 100755 index 000000000..d4abcad26 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbSchemaBuilderTest.php @@ -0,0 +1,44 @@ +shouldReceive('getDatabaseName')->andReturn('db'); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $builder = new MariaDbBuilder($connection); + $grammar->shouldReceive('compileTableExists')->once()->andReturn('sql'); + $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); + $connection->shouldReceive('scalar')->once()->with('sql')->andReturn(1); + + $this->assertTrue($builder->hasTable('table')); + } + + public function testGetColumnListing() + { + $connection = m::mock(Connection::class); + $grammar = m::mock(MariaDbGrammar::class); + $processor = m::mock(MariaDbProcessor::class); + $connection->shouldReceive('getDatabaseName')->andReturn('db'); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $grammar->shouldReceive('compileColumns')->with(null, 'prefix_table')->once()->andReturn('sql'); + $processor->shouldReceive('processColumns')->once()->andReturn([['name' => 'column']]); + $builder = new MariaDbBuilder($connection); + $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('sql')->andReturn([['name' => 'column']]); + + $this->assertEquals(['column'], $builder->getColumnListing('table')); + } +} diff --git a/tests/Database/Laravel/DatabaseMariaDbSchemaGrammarTest.php b/tests/Database/Laravel/DatabaseMariaDbSchemaGrammarTest.php new file mode 100755 index 000000000..fd5818cc5 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbSchemaGrammarTest.php @@ -0,0 +1,1580 @@ +getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table `users` add `id` int unsigned not null auto_increment primary key', + 'alter table `users` add `email` varchar(255) not null', + ], $statements); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + $conn->shouldReceive('getServerVersion')->andReturn('10.7.0'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->uuid('id')->primary(); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table `users` (`id` uuid not null, primary key (`id`))', $statements[0]); + } + + public function testAutoIncrementStartingValue() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id')->startingValue(1000); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + $this->assertSame('alter table `users` auto_increment = 1000', $statements[1]); + } + + public function testAddColumnsWithMultipleAutoIncrementStartingValue() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id()->from(100); + $blueprint->string('name')->from(200); + $statements = $blueprint->toSql(); + + $this->assertEquals([ + 'alter table `users` add `id` bigint unsigned not null auto_increment primary key', + 'alter table `users` add `name` varchar(255) not null', + 'alter table `users` auto_increment = 100', + ], $statements); + } + + public function testEngineCreateTable() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + $blueprint->engine('InnoDB'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8 collate 'utf8_unicode_ci' engine = InnoDB", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn('InnoDB'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8 collate 'utf8_unicode_ci' engine = InnoDB", $statements[0]); + } + + public function testCharsetCollationCreateTable() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + $blueprint->charset('utf8mb4'); + $blueprint->collation('utf8mb4_unicode_ci'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8mb4 collate 'utf8mb4_unicode_ci'", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email')->charset('utf8mb4')->collation('utf8mb4_unicode_ci'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) character set utf8mb4 collate 'utf8mb4_unicode_ci' not null) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + } + + public function testBasicCreateTableWithPrefix() + { + $conn = $this->getConnection(prefix: 'prefix_'); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table `prefix_users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null)', $statements[0]); + } + + public function testCreateTemporaryTable() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->temporary(); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create temporary table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null)', $statements[0]); + } + + public function testDropTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->drop(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop table `users`', $statements[0]); + } + + public function testDropTableIfExists() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropIfExists(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop table if exists `users`', $statements[0]); + } + + public function testDropColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `foo`', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn(['foo', 'bar']); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `foo`, drop `bar`', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `foo`, drop `bar`', $statements[0]); + } + + public function testDropPrimary() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropPrimary(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop primary key', $statements[0]); + } + + public function testDropUnique() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropUnique('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop index `foo`', $statements[0]); + } + + public function testDropIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropIndex('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop index `foo`', $statements[0]); + } + + public function testDropSpatialIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->dropSpatialIndex(['coordinates']); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` drop index `geo_coordinates_spatialindex`', $statements[0]); + } + + public function testDropForeign() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropForeign('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop foreign key `foo`', $statements[0]); + } + + public function testDropTimestamps() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropTimestamps(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `created_at`, drop `updated_at`', $statements[0]); + } + + public function testDropTimestampsTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropTimestampsTz(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `created_at`, drop `updated_at`', $statements[0]); + } + + public function testDropMorphs() + { + $blueprint = new Blueprint($this->getConnection(), 'photos'); + $blueprint->dropMorphs('imageable'); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table `photos` drop index `photos_imageable_type_imageable_id_index`', $statements[0]); + $this->assertSame('alter table `photos` drop `imageable_type`, drop `imageable_id`', $statements[1]); + } + + public function testRenameTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rename('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('rename table `users` to `foo`', $statements[0]); + } + + public function testRenameIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->renameIndex('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` rename index `foo` to `bar`', $statements[0]); + } + + public function testAddingPrimaryKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->primary('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add primary key (`foo`)', $statements[0]); + } + + public function testAddingPrimaryKeyWithAlgorithm() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->primary('foo', 'bar', 'hash'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add primary key using hash(`foo`)', $statements[0]); + } + + public function testAddingUniqueKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->unique('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add unique `bar`(`foo`)', $statements[0]); + } + + public function testAddingIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index(['foo', 'bar'], 'baz'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add index `baz`(`foo`, `bar`)', $statements[0]); + } + + public function testAddingIndexWithAlgorithm() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index(['foo', 'bar'], 'baz', 'hash'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add index `baz` using hash(`foo`, `bar`)', $statements[0]); + } + + public function testAddingFulltextIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->fulltext('body'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add fulltext `users_body_fulltext`(`body`)', $statements[0]); + } + + public function testAddingSpatialIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->spatialIndex('coordinates'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add spatial index `geo_coordinates_spatialindex`(`coordinates`)', $statements[0]); + } + + public function testAddingFluentSpatialIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point')->spatialIndex(); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table `geo` add spatial index `geo_coordinates_spatialindex`(`coordinates`)', $statements[1]); + } + + public function testAddingRawIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rawIndex('(function(column))', 'raw_index'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add index `raw_index`((function(column)))', $statements[0]); + } + + public function testAddingForeignKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('foo_id')->references('id')->on('orders'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add constraint `users_foo_id_foreign` foreign key (`foo_id`) references `orders` (`id`)', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('foo_id')->references('id')->on('orders')->cascadeOnDelete(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add constraint `users_foo_id_foreign` foreign key (`foo_id`) references `orders` (`id`) on delete cascade', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('foo_id')->references('id')->on('orders')->cascadeOnUpdate(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add constraint `users_foo_id_foreign` foreign key (`foo_id`) references `orders` (`id`) on update cascade', $statements[0]); + } + + public function testAddingIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->increments('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `id` int unsigned not null auto_increment primary key', $statements[0]); + } + + public function testAddingSmallIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `id` smallint unsigned not null auto_increment primary key', $statements[0]); + } + + public function testAddingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `id` bigint unsigned not null auto_increment primary key', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` bigint unsigned not null auto_increment primary key', $statements[0]); + } + + public function testAddingForeignID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $foreignId = $blueprint->foreignId('foo'); + $blueprint->foreignId('company_id')->constrained(); + $blueprint->foreignId('laravel_idea_id')->constrained(); + $blueprint->foreignId('team_id')->references('id')->on('teams'); + $blueprint->foreignId('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql(); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignId); + $this->assertSame([ + 'alter table `users` add `foo` bigint unsigned not null', + 'alter table `users` add `company_id` bigint unsigned not null', + 'alter table `users` add constraint `users_company_id_foreign` foreign key (`company_id`) references `companies` (`id`)', + 'alter table `users` add `laravel_idea_id` bigint unsigned not null', + 'alter table `users` add constraint `users_laravel_idea_id_foreign` foreign key (`laravel_idea_id`) references `laravel_ideas` (`id`)', + 'alter table `users` add `team_id` bigint unsigned not null', + 'alter table `users` add constraint `users_team_id_foreign` foreign key (`team_id`) references `teams` (`id`)', + 'alter table `users` add `team_column_id` bigint unsigned not null', + 'alter table `users` add constraint `users_team_column_id_foreign` foreign key (`team_column_id`) references `teams` (`id`)', + ], $statements); + } + + public function testAddingForeignIdSpecifyingIndexNameInConstraint() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreignId('company_id')->constrained(indexName: 'my_index'); + $statements = $blueprint->toSql(); + $this->assertSame([ + 'alter table `users` add `company_id` bigint unsigned not null', + 'alter table `users` add constraint `my_index` foreign key (`company_id`) references `companies` (`id`)', + ], $statements); + } + + public function testAddingBigIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `id` bigint unsigned not null auto_increment primary key', $statements[0]); + } + + public function testAddingColumnInTableFirst() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('name')->first(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `name` varchar(255) not null first', $statements[0]); + } + + public function testAddingColumnAfterAnotherColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('name')->after('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `name` varchar(255) not null after `foo`', $statements[0]); + } + + public function testAddingMultipleColumnsAfterAnotherColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->after('foo', function ($blueprint) { + $blueprint->string('one'); + $blueprint->string('two'); + }); + $blueprint->string('three'); + $statements = $blueprint->toSql(); + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `users` add `one` varchar(255) not null after `foo`', + 'alter table `users` add `two` varchar(255) not null after `one`', + 'alter table `users` add `three` varchar(255) not null', + ], $statements); + } + + public function testAddingGeneratedColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'products'); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('price - 5'); + $blueprint->integer('discounted_stored')->storedAs('price - 5'); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `products` add `price` int not null', + 'alter table `products` add `discounted_virtual` int as (price - 5)', + 'alter table `products` add `discounted_stored` int as (price - 5) stored', + ], $statements); + + $blueprint = new Blueprint($this->getConnection(), 'products'); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('price - 5')->nullable(false); + $blueprint->integer('discounted_stored')->storedAs('price - 5')->nullable(false); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `products` add `price` int not null', + 'alter table `products` add `discounted_virtual` int as (price - 5) not null', + 'alter table `products` add `discounted_stored` int as (price - 5) stored not null', + ], $statements); + } + + public function testAddingGeneratedColumnWithCharset() + { + $blueprint = new Blueprint($this->getConnection(), 'links'); + $blueprint->string('url', 2083)->charset('ascii'); + $blueprint->string('url_hash_virtual', 64)->virtualAs('sha2(url, 256)')->charset('ascii'); + $blueprint->string('url_hash_stored', 64)->storedAs('sha2(url, 256)')->charset('ascii'); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `links` add `url` varchar(2083) character set ascii not null', + 'alter table `links` add `url_hash_virtual` varchar(64) character set ascii as (sha2(url, 256))', + 'alter table `links` add `url_hash_stored` varchar(64) character set ascii as (sha2(url, 256)) stored', + ], $statements); + } + + public function testAddingGeneratedColumnByExpression() + { + $blueprint = new Blueprint($this->getConnection(), 'products'); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs(new Expression('price - 5')); + $blueprint->integer('discounted_stored')->storedAs(new Expression('price - 5')); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `products` add `price` int not null', + 'alter table `products` add `discounted_virtual` int as (price - 5)', + 'alter table `products` add `discounted_stored` int as (price - 5) stored', + ], $statements); + } + + public function testAddingInvisibleColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('secret', 64)->nullable(false)->invisible(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `secret` varchar(64) not null invisible', $statements[0]); + } + + public function testAddingString() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(255) not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(100) not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100)->nullable()->default('bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(100) null default \'bar\'', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100)->nullable()->default(new Expression('CURRENT TIMESTAMP')); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(100) null default CURRENT TIMESTAMP', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100)->nullable()->default(Foo::BAR); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(100) null default \'bar\'', $statements[0]); + } + + public function testAddingText() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->text('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` text not null', $statements[0]); + } + + public function testAddingBigInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` bigint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` bigint not null auto_increment primary key', $statements[0]); + } + + public function testAddingInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` int not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` int not null auto_increment primary key', $statements[0]); + } + + public function testAddingIncrementsWithStartingValues() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id()->startingValue(1000); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table `users` add `id` bigint unsigned not null auto_increment primary key', $statements[0]); + $this->assertSame('alter table `users` auto_increment = 1000', $statements[1]); + } + + public function testAddingMediumInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` mediumint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` mediumint not null auto_increment primary key', $statements[0]); + } + + public function testAddingSmallInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` smallint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` smallint not null auto_increment primary key', $statements[0]); + } + + public function testAddingTinyInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->tinyInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` tinyint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->tinyInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` tinyint not null auto_increment primary key', $statements[0]); + } + + public function testAddingFloat() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->float('foo', 5); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` float(5) not null', $statements[0]); + } + + public function testAddingDouble() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->double('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` double not null', $statements[0]); + } + + public function testAddingDecimal() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->decimal('foo', 5, 2); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` decimal(5, 2) not null', $statements[0]); + } + + public function testAddingBoolean() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->boolean('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` tinyint(1) not null', $statements[0]); + } + + public function testAddingEnum() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->enum('role', ['member', 'admin']); + $blueprint->enum('status', Foo::cases()); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table `users` add `role` enum(\'member\', \'admin\') not null', $statements[0]); + $this->assertSame('alter table `users` add `status` enum(\'bar\') not null', $statements[1]); + } + + public function testAddingSet() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->set('role', ['member', 'admin']); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `role` set(\'member\', \'admin\') not null', $statements[0]); + } + + public function testAddingJson() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->json('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` json not null', $statements[0]); + } + + public function testAddingJsonb() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->jsonb('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` json not null', $statements[0]); + } + + public function testAddingDate() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(true); + $conn->shouldReceive('getServerVersion')->andReturn('10.3.0'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->date('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` date not null', $statements[0]); + } + + public function testAddingDateWithDefaultCurrent() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(true); + $conn->shouldReceive('getServerVersion')->andReturn('10.3.0'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->date('foo')->useCurrent(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` date not null default (CURDATE())', $statements[0]); + } + + public function testAddingYear() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(true); + $conn->shouldReceive('getServerVersion')->andReturn('10.3.0'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->year('birth_year'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `birth_year` year not null', $statements[0]); + } + + public function testAddingYearWithDefaultCurrent() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(true); + $conn->shouldReceive('getServerVersion')->andReturn('10.3.0'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->year('birth_year')->useCurrent(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `birth_year` year not null default (YEAR(CURDATE()))', $statements[0]); + } + + public function testAddingDateTime() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime(1) not null', $statements[0]); + } + + public function testAddingDateTimeWithDefaultCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo')->useCurrent(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null default CURRENT_TIMESTAMP', $statements[0]); + } + + public function testAddingDateTimeWithOnUpdateCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo')->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null on update CURRENT_TIMESTAMP', $statements[0]); + } + + public function testAddingDateTimeWithDefaultCurrentAndOnUpdateCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo')->useCurrent()->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP', $statements[0]); + } + + public function testAddingDateTimeWithDefaultCurrentOnUpdateCurrentAndPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo', 3)->useCurrent()->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime(3) not null default CURRENT_TIMESTAMP(3) on update CURRENT_TIMESTAMP(3)', $statements[0]); + } + + public function testAddingDateTimeTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTimeTz('foo', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime(1) not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTimeTz('foo'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null', $statements[0]); + } + + public function testAddingTime() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->time('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` time not null', $statements[0]); + } + + public function testAddingTimeWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->time('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` time(1) not null', $statements[0]); + } + + public function testAddingTimeTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timeTz('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` time not null', $statements[0]); + } + + public function testAddingTimeTzWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timeTz('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` time(1) not null', $statements[0]); + } + + public function testAddingTimestamp() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp not null', $statements[0]); + } + + public function testAddingTimestampWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null', $statements[0]); + } + + public function testAddingTimestampWithDefault() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at')->default('2015-07-22 11:43:17'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame("alter table `users` add `created_at` timestamp not null default '2015-07-22 11:43:17'", $statements[0]); + } + + public function testAddingTimestampWithDefaultCurrentSpecifyingPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at', 1)->useCurrent(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null default CURRENT_TIMESTAMP(1)', $statements[0]); + } + + public function testAddingTimestampWithOnUpdateCurrentSpecifyingPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at', 1)->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null on update CURRENT_TIMESTAMP(1)', $statements[0]); + } + + public function testAddingTimestampWithDefaultCurrentAndOnUpdateCurrentSpecifyingPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at', 1)->useCurrent()->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null default CURRENT_TIMESTAMP(1) on update CURRENT_TIMESTAMP(1)', $statements[0]); + } + + public function testAddingTimestampTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampTz('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp not null', $statements[0]); + } + + public function testAddingTimestampTzWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampTz('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null', $statements[0]); + } + + public function testAddingTimeStampTzWithDefault() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampTz('created_at')->default('2015-07-22 11:43:17'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame("alter table `users` add `created_at` timestamp not null default '2015-07-22 11:43:17'", $statements[0]); + } + + public function testAddingTimestamps() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamps(); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table `users` add `created_at` timestamp null', + 'alter table `users` add `updated_at` timestamp null', + ], $statements); + } + + public function testAddingTimestampsTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampsTz(); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table `users` add `created_at` timestamp null', + 'alter table `users` add `updated_at` timestamp null', + ], $statements); + } + + public function testAddingRememberToken() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rememberToken(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `remember_token` varchar(100) null', $statements[0]); + } + + public function testAddingBinary() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->binary('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` blob not null', $statements[0]); + } + + public function testAddingUuid() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getServerVersion')->andReturn('10.7.0'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->uuid('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` uuid not null', $statements[0]); + } + + public function testAddingUuidOn106() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getServerVersion')->andReturn('10.6.21'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->uuid('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` char(36) not null', $statements[0]); + } + + public function testAddingUuidDefaultsColumnName() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getServerVersion')->andReturn('10.7.0'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->uuid(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `uuid` uuid not null', $statements[0]); + } + + public function testAddingForeignUuid() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getServerVersion')->andReturn('10.7.0'); + + $blueprint = new Blueprint($conn, 'users'); + $foreignUuid = $blueprint->foreignUuid('foo'); + $blueprint->foreignUuid('company_id')->constrained(); + $blueprint->foreignUuid('laravel_idea_id')->constrained(); + $blueprint->foreignUuid('team_id')->references('id')->on('teams'); + $blueprint->foreignUuid('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql(); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignUuid); + $this->assertSame([ + 'alter table `users` add `foo` uuid not null', + 'alter table `users` add `company_id` uuid not null', + 'alter table `users` add constraint `users_company_id_foreign` foreign key (`company_id`) references `companies` (`id`)', + 'alter table `users` add `laravel_idea_id` uuid not null', + 'alter table `users` add constraint `users_laravel_idea_id_foreign` foreign key (`laravel_idea_id`) references `laravel_ideas` (`id`)', + 'alter table `users` add `team_id` uuid not null', + 'alter table `users` add constraint `users_team_id_foreign` foreign key (`team_id`) references `teams` (`id`)', + 'alter table `users` add `team_column_id` uuid not null', + 'alter table `users` add constraint `users_team_column_id_foreign` foreign key (`team_column_id`) references `teams` (`id`)', + ], $statements); + } + + public function testAddingIpAddress() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->ipAddress('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(45) not null', $statements[0]); + } + + public function testAddingIpAddressDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->ipAddress(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `ip_address` varchar(45) not null', $statements[0]); + } + + public function testAddingMacAddress() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->macAddress('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(17) not null', $statements[0]); + } + + public function testAddingMacAddressDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->macAddress(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `mac_address` varchar(17) not null', $statements[0]); + } + + public function testAddingGeometry() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` geometry not null', $statements[0]); + } + + public function testAddingGeography() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geography('coordinates'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` geometry ref_system_id=4326 not null', $statements[0]); + } + + public function testAddingPoint() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` point not null', $statements[0]); + } + + public function testAddingPointWithSrid() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point', 4326); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` point ref_system_id=4326 not null', $statements[0]); + } + + public function testAddingPointWithSridColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point', 4326)->after('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` point ref_system_id=4326 not null after `id`', $statements[0]); + } + + public function testAddingLineString() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'linestring'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` linestring not null', $statements[0]); + } + + public function testAddingPolygon() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'polygon'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` polygon not null', $statements[0]); + } + + public function testAddingGeometryCollection() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'geometrycollection'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` geometrycollection not null', $statements[0]); + } + + public function testAddingMultiPoint() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'multipoint'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` multipoint not null', $statements[0]); + } + + public function testAddingMultiLineString() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'multilinestring'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` multilinestring not null', $statements[0]); + } + + public function testAddingMultiPolygon() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'multipolygon'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` multipolygon not null', $statements[0]); + } + + public function testAddingComment() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo')->comment("Escape ' when using words like it's"); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("alter table `users` add `foo` varchar(255) not null comment 'Escape \\' when using words like it\\'s'", $statements[0]); + } + + public function testCreateDatabase() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8mb4_foo'); + $connection->shouldReceive('getConfig')->once()->once()->with('collation')->andReturn('utf8mb4_unicode_ci_foo'); + + $statement = $this->getGrammar($connection)->compileCreateDatabase('my_database_a'); + + $this->assertSame( + 'create database `my_database_a` default character set `utf8mb4_foo` default collate `utf8mb4_unicode_ci_foo`', + $statement + ); + + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8mb4_bar'); + $connection->shouldReceive('getConfig')->once()->once()->with('collation')->andReturn('utf8mb4_unicode_ci_bar'); + + $statement = $this->getGrammar($connection)->compileCreateDatabase('my_database_b'); + + $this->assertSame( + 'create database `my_database_b` default character set `utf8mb4_bar` default collate `utf8mb4_unicode_ci_bar`', + $statement + ); + } + + public function testCreateTableWithVirtualAsColumn() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->virtualAs('my_column'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_column` varchar(255) not null, `my_other_column` varchar(255) as (my_column)) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAsJson('my_json_column->some_attribute'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_value(`my_json_column`, '$.\"some_attribute\"')))", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAsJson('my_json_column->some_attribute->nested'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_value(`my_json_column`, '$.\"some_attribute\".\"nested\"')))", $statements[0]); + } + + public function testCreateTableWithVirtualAsColumnWhenJsonColumnHasArrayKey() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column')->virtualAsJson('my_json_column->foo[0][1]'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) as (json_value(`my_json_column`, '$.\"foo\"[0][1]')))", $statements[0]); + } + + public function testCreateTableWithStoredAsColumn() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->storedAs('my_column'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_column` varchar(255) not null, `my_other_column` varchar(255) as (my_column) stored) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAsJson('my_json_column->some_attribute'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_value(`my_json_column`, '$.\"some_attribute\"')) stored)", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAsJson('my_json_column->some_attribute->nested'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_value(`my_json_column`, '$.\"some_attribute\".\"nested\"')) stored)", $statements[0]); + } + + public function testDropDatabaseIfExists() + { + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_a'); + + $this->assertSame( + 'drop database if exists `my_database_a`', + $statement + ); + + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_b'); + + $this->assertSame( + 'drop database if exists `my_database_b`', + $statement + ); + } + + public function testDropAllTables() + { + $connection = $this->getConnection(); + $statement = $this->getGrammar($connection)->compileDropAllTables(['alpha', 'beta', 'gamma']); + + $this->assertSame('drop table `alpha`, `beta`, `gamma`', $statement); + } + + public function testDropAllViews() + { + $statement = $this->getGrammar()->compileDropAllViews(['alpha', 'beta', 'gamma']); + + $this->assertSame('drop view `alpha`, `beta`, `gamma`', $statement); + } + + public function testGrammarsAreMacroable() + { + // compileReplace macro. + $this->getGrammar()::macro('compileReplace', function () { + return true; + }); + + $c = $this->getGrammar()::compileReplace(); + + $this->assertTrue($c); + } + + protected function getConnection( + ?MariaDbGrammar $grammar = null, + ?MariaDbBuilder $builder = null, + string $prefix = '' + ) { + $connection = m::mock(Connection::class) + ->shouldReceive('getTablePrefix')->andReturn($prefix) + ->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(null) + ->getMock(); + + $grammar ??= $this->getGrammar($connection); + $builder ??= $this->getBuilder(); + + return $connection + ->shouldReceive('getSchemaGrammar')->andReturn($grammar) + ->shouldReceive('getSchemaBuilder')->andReturn($builder) + ->getMock(); + } + + public function getGrammar(?Connection $connection = null) + { + return new MariaDbGrammar($connection ?? $this->getConnection()); + } + + public function getBuilder() + { + return mock(MariaDbBuilder::class); + } +} diff --git a/tests/Database/Laravel/DatabaseMariaDbSchemaStateTest.php b/tests/Database/Laravel/DatabaseMariaDbSchemaStateTest.php new file mode 100644 index 000000000..109a5ab17 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbSchemaStateTest.php @@ -0,0 +1,87 @@ +createMock(MariaDbConnection::class); + $connection->method('getConfig')->willReturn($dbConfig); + + $schemaState = new MariaDbSchemaState($connection); + + // test connectionString + $method = new ReflectionMethod(get_class($schemaState), 'connectionString'); + $connString = $method->invoke($schemaState); + + self::assertEquals($expectedConnectionString, $connString); + + // test baseVariables + $method = new ReflectionMethod(get_class($schemaState), 'baseVariables'); + $variables = $method->invoke($schemaState, $dbConfig); + + self::assertEquals($expectedVariables, $variables); + } + + public static function provider(): Generator + { + yield 'default' => [ + ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}" --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}"', [ + 'LARAVEL_LOAD_SOCKET' => '', + 'LARAVEL_LOAD_HOST' => '127.0.0.1', + 'LARAVEL_LOAD_PORT' => '', + 'LARAVEL_LOAD_USER' => 'root', + 'LARAVEL_LOAD_PASSWORD' => '', + 'LARAVEL_LOAD_DATABASE' => 'forge', + 'LARAVEL_LOAD_SSL_CA' => '', + ], [ + 'username' => 'root', + 'host' => '127.0.0.1', + 'database' => 'forge', + ], + ]; + + yield 'ssl_ca' => [ + ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}" --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --ssl-ca="${:LARAVEL_LOAD_SSL_CA}"', [ + 'LARAVEL_LOAD_SOCKET' => '', + 'LARAVEL_LOAD_HOST' => '', + 'LARAVEL_LOAD_PORT' => '', + 'LARAVEL_LOAD_USER' => 'root', + 'LARAVEL_LOAD_PASSWORD' => '', + 'LARAVEL_LOAD_DATABASE' => 'forge', + 'LARAVEL_LOAD_SSL_CA' => 'ssl.ca', + ], [ + 'username' => 'root', + 'database' => 'forge', + 'options' => [ + PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA => 'ssl.ca', + ], + ], + ]; + + yield 'unix socket' => [ + ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}" --socket="${:LARAVEL_LOAD_SOCKET}"', [ + 'LARAVEL_LOAD_SOCKET' => '/tmp/mysql.sock', + 'LARAVEL_LOAD_HOST' => '', + 'LARAVEL_LOAD_PORT' => '', + 'LARAVEL_LOAD_USER' => 'root', + 'LARAVEL_LOAD_PASSWORD' => '', + 'LARAVEL_LOAD_DATABASE' => 'forge', + 'LARAVEL_LOAD_SSL_CA' => '', + ], [ + 'username' => 'root', + 'database' => 'forge', + 'unix_socket' => '/tmp/mysql.sock', + ], + ]; + } +} diff --git a/tests/Database/Laravel/DatabaseMigrationCreatorTest.php b/tests/Database/Laravel/DatabaseMigrationCreatorTest.php new file mode 100755 index 000000000..0ee210157 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationCreatorTest.php @@ -0,0 +1,106 @@ +getCreator(); + + $creator->expects($this->any())->method('getDatePrefix')->willReturn('foo'); + $creator->getFilesystem()->shouldReceive('exists')->once()->with('stubs/migration.stub')->andReturn(false); + $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/migration.stub')->andReturn('return new class'); + $creator->getFilesystem()->shouldReceive('ensureDirectoryExists')->once()->with('foo'); + $creator->getFilesystem()->shouldReceive('put')->once()->with('foo/foo_create_bar.php', 'return new class'); + $creator->getFilesystem()->shouldReceive('glob')->once()->with('foo/*.php')->andReturn(['foo/foo_create_bar.php']); + $creator->getFilesystem()->shouldReceive('requireOnce')->once()->with('foo/foo_create_bar.php'); + + $creator->create('create_bar', 'foo'); + } + + public function testBasicCreateMethodCallsPostCreateHooks() + { + $table = 'baz'; + + $creator = $this->getCreator(); + unset($_SERVER['__migration.creator.table'], $_SERVER['__migration.creator.path']); + $creator->afterCreate(function ($table, $path) { + $_SERVER['__migration.creator.table'] = $table; + $_SERVER['__migration.creator.path'] = $path; + }); + + $creator->expects($this->any())->method('getDatePrefix')->willReturn('foo'); + $creator->getFilesystem()->shouldReceive('exists')->once()->with('stubs/migration.update.stub')->andReturn(false); + $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/migration.update.stub')->andReturn('return new class DummyTable'); + $creator->getFilesystem()->shouldReceive('ensureDirectoryExists')->once()->with('foo'); + $creator->getFilesystem()->shouldReceive('put')->once()->with('foo/foo_create_bar.php', 'return new class baz'); + $creator->getFilesystem()->shouldReceive('glob')->once()->with('foo/*.php')->andReturn(['foo/foo_create_bar.php']); + $creator->getFilesystem()->shouldReceive('requireOnce')->once()->with('foo/foo_create_bar.php'); + + $creator->create('create_bar', 'foo', $table); + + $this->assertEquals($_SERVER['__migration.creator.table'], $table); + $this->assertEquals($_SERVER['__migration.creator.path'], 'foo/foo_create_bar.php'); + + unset($_SERVER['__migration.creator.table'], $_SERVER['__migration.creator.path']); + } + + public function testTableUpdateMigrationStoresMigrationFile() + { + $creator = $this->getCreator(); + $creator->expects($this->any())->method('getDatePrefix')->willReturn('foo'); + $creator->getFilesystem()->shouldReceive('exists')->once()->with('stubs/migration.update.stub')->andReturn(false); + $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/migration.update.stub')->andReturn('return new class DummyTable'); + $creator->getFilesystem()->shouldReceive('ensureDirectoryExists')->once()->with('foo'); + $creator->getFilesystem()->shouldReceive('put')->once()->with('foo/foo_create_bar.php', 'return new class baz'); + $creator->getFilesystem()->shouldReceive('glob')->once()->with('foo/*.php')->andReturn(['foo/foo_create_bar.php']); + $creator->getFilesystem()->shouldReceive('requireOnce')->once()->with('foo/foo_create_bar.php'); + + $creator->create('create_bar', 'foo', 'baz'); + } + + public function testTableCreationMigrationStoresMigrationFile() + { + $creator = $this->getCreator(); + $creator->expects($this->any())->method('getDatePrefix')->willReturn('foo'); + $creator->getFilesystem()->shouldReceive('exists')->once()->with('stubs/migration.create.stub')->andReturn(false); + $creator->getFilesystem()->shouldReceive('get')->once()->with($creator->stubPath().'/migration.create.stub')->andReturn('return new class DummyTable'); + $creator->getFilesystem()->shouldReceive('ensureDirectoryExists')->once()->with('foo'); + $creator->getFilesystem()->shouldReceive('put')->once()->with('foo/foo_create_bar.php', 'return new class baz'); + $creator->getFilesystem()->shouldReceive('glob')->once()->with('foo/*.php')->andReturn(['foo/foo_create_bar.php']); + $creator->getFilesystem()->shouldReceive('requireOnce')->once()->with('foo/foo_create_bar.php'); + + $creator->create('create_bar', 'foo', 'baz', true); + } + + public function testTableUpdateMigrationWontCreateDuplicateClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A MigrationCreatorFakeMigration class already exists.'); + + $creator = $this->getCreator(); + + $creator->getFilesystem()->shouldReceive('glob')->once()->with('foo/*.php')->andReturn(['foo/foo_create_bar.php']); + $creator->getFilesystem()->shouldReceive('requireOnce')->once()->with('foo/foo_create_bar.php'); + + $creator->create('migration_creator_fake_migration', 'foo'); + } + + protected function getCreator() + { + $files = m::mock(Filesystem::class); + $customStubs = 'stubs'; + + return $this->getMockBuilder(MigrationCreator::class) + ->onlyMethods(['getDatePrefix']) + ->setConstructorArgs([$files, $customStubs]) + ->getMock(); + } +} diff --git a/tests/Database/Laravel/DatabaseMigrationInstallCommandTest.php b/tests/Database/Laravel/DatabaseMigrationInstallCommandTest.php new file mode 100755 index 000000000..a23ba6188 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationInstallCommandTest.php @@ -0,0 +1,40 @@ +setLaravel(new Application); + $repo->shouldReceive('setSource')->once()->with('foo'); + $repo->shouldReceive('createRepository')->once(); + $repo->shouldReceive('repositoryExists')->once()->andReturn(false); + + $this->runCommand($command, ['--database' => 'foo']); + } + + public function testFireCallsRepositoryToInstallExists() + { + $command = new InstallCommand($repo = m::mock(MigrationRepositoryInterface::class)); + $command->setLaravel(new Application); + $repo->shouldReceive('setSource')->once()->with('foo'); + $repo->shouldReceive('repositoryExists')->once()->andReturn(true); + + $this->runCommand($command, ['--database' => 'foo']); + } + + protected function runCommand($command, $options = []) + { + return $command->run(new ArrayInput($options), new NullOutput); + } +} diff --git a/tests/Database/Laravel/DatabaseMigrationMakeCommandTest.php b/tests/Database/Laravel/DatabaseMigrationMakeCommandTest.php new file mode 100755 index 000000000..d54b5f1cd --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationMakeCommandTest.php @@ -0,0 +1,115 @@ +useDatabasePath(__DIR__); + $command->setLaravel($app); + $creator->shouldReceive('create')->once() + ->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'foo', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_foo.php'); + + $this->runCommand($command, ['name' => 'create_foo']); + } + + public function testBasicCreateGivesCreatorProperArguments() + { + $command = new MigrateMakeCommand( + $creator = m::mock(MigrationCreator::class), + m::mock(Composer::class)->shouldIgnoreMissing() + ); + $app = new Application; + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $creator->shouldReceive('create')->once() + ->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'foo', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_foo.php'); + + $this->runCommand($command, ['name' => 'create_foo']); + } + + public function testBasicCreateGivesCreatorProperArgumentsWhenNameIsStudlyCase() + { + $command = new MigrateMakeCommand( + $creator = m::mock(MigrationCreator::class), + m::mock(Composer::class)->shouldIgnoreMissing() + ); + $app = new Application; + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $creator->shouldReceive('create')->once() + ->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'foo', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_foo.php'); + + $this->runCommand($command, ['name' => 'CreateFoo']); + } + + public function testBasicCreateGivesCreatorProperArgumentsWhenTableIsSet() + { + $command = new MigrateMakeCommand( + $creator = m::mock(MigrationCreator::class), + m::mock(Composer::class)->shouldIgnoreMissing() + ); + $app = new Application; + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $creator->shouldReceive('create')->once() + ->with('create_foo', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'users', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_foo.php'); + + $this->runCommand($command, ['name' => 'create_foo', '--create' => 'users']); + } + + public function testBasicCreateGivesCreatorProperArgumentsWhenCreateTablePatternIsFound() + { + $command = new MigrateMakeCommand( + $creator = m::mock(MigrationCreator::class), + m::mock(Composer::class)->shouldIgnoreMissing() + ); + $app = new Application; + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $creator->shouldReceive('create')->once() + ->with('create_users_table', __DIR__.DIRECTORY_SEPARATOR.'migrations', 'users', true) + ->andReturn(__DIR__.'/migrations/2021_04_23_110457_create_users_table.php'); + + $this->runCommand($command, ['name' => 'create_users_table']); + } + + public function testCanSpecifyPathToCreateMigrationsIn() + { + $command = new MigrateMakeCommand( + $creator = m::mock(MigrationCreator::class), + m::mock(Composer::class)->shouldIgnoreMissing() + ); + $app = new Application; + $command->setLaravel($app); + $app->setBasePath('/home/laravel'); + $creator->shouldReceive('create')->once() + ->with('create_foo', '/home/laravel/vendor/laravel-package/migrations', 'users', true) + ->andReturn('/home/laravel/vendor/laravel-package/migrations/2021_04_23_110457_create_foo.php'); + $this->runCommand($command, ['name' => 'create_foo', '--path' => 'vendor/laravel-package/migrations', '--create' => 'users']); + } + + protected function runCommand($command, $input = []) + { + return $command->run(new ArrayInput($input), new NullOutput); + } +} diff --git a/tests/Database/Laravel/DatabaseMigrationMigrateCommandTest.php b/tests/Database/Laravel/DatabaseMigrationMigrateCommandTest.php new file mode 100755 index 000000000..81f979d16 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationMigrateCommandTest.php @@ -0,0 +1,162 @@ + __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => false]); + $migrator->shouldReceive('getNotes')->andReturn([]); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); + + $this->runCommand($command); + } + + public function testMigrationsCanBeRunWithStoredSchema() + { + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); + $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(false); + $migrator->shouldReceive('resolveConnection')->andReturn($connection = m::mock(stdClass::class)); + $connection->shouldReceive('getName')->andReturn('mysql'); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('deleteRepository')->once(); + $connection->shouldReceive('getSchemaState')->andReturn($schemaState = m::mock(stdClass::class)); + $schemaState->shouldReceive('handleOutputUsing')->andReturnSelf(); + $schemaState->shouldReceive('load')->once()->with(__DIR__.'/stubs/schema.sql'); + $dispatcher->shouldReceive('dispatch')->once()->with(m::type(SchemaLoaded::class)); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => false]); + $migrator->shouldReceive('getNotes')->andReturn([]); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); + + $this->runCommand($command, ['--schema-path' => __DIR__.'/stubs/schema.sql']); + } + + public function testMigrationRepositoryCreatedWhenNecessary() + { + $params = [$migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)]; + $command = $this->getMockBuilder(MigrateCommand::class)->onlyMethods(['callSilent'])->setConstructorArgs($params)->getMock(); + $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => false]); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(false); + $command->expects($this->once())->method('callSilent')->with($this->equalTo('migrate:install'), $this->equalTo([])); + + $this->runCommand($command); + } + + public function testTheCommandMayBePretended() + { + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); + $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => true, 'step' => false]); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); + + $this->runCommand($command, ['--pretend' => true]); + } + + public function testTheDatabaseMayBeSet() + { + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); + $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => false]); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); + + $this->runCommand($command, ['--database' => 'foo']); + } + + public function testStepMayBeSet() + { + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); + $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('run')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => true]); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); + + $this->runCommand($command, ['--step' => true]); + } + + protected function runCommand($command, $input = []) + { + return $command->run(new ArrayInput($input), new NullOutput); + } +} + +class ApplicationDatabaseMigrationStub extends Application +{ + public function __construct(array $data = []) + { + $mutex = m::mock(CommandMutex::class); + $mutex->shouldReceive('create')->andReturn(true); + $mutex->shouldReceive('release')->andReturn(true); + $this->instance(CommandMutex::class, $mutex); + + foreach ($data as $abstract => $instance) { + $this->instance($abstract, $instance); + } + } + + public function environment(...$environments) + { + return 'development'; + } +} diff --git a/tests/Database/Laravel/DatabaseMigrationRefreshCommandTest.php b/tests/Database/Laravel/DatabaseMigrationRefreshCommandTest.php new file mode 100755 index 000000000..ec36c1633 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationRefreshCommandTest.php @@ -0,0 +1,134 @@ + __DIR__]); + $dispatcher = $app->instance(Dispatcher::class, $events = m::mock()); + $console = m::mock(ConsoleApplication::class)->makePartial(); + $console->__construct(); + $command->setLaravel($app); + $command->setApplication($console); + + $resetCommand = m::mock(ResetCommand::class); + $migrateCommand = m::mock(MigrateCommand::class); + + $console->shouldReceive('find')->with('migrate:reset')->andReturn($resetCommand); + $console->shouldReceive('find')->with('migrate')->andReturn($migrateCommand); + $dispatcher->shouldReceive('dispatch')->once()->with(m::type(DatabaseRefreshed::class)); + + $quote = DIRECTORY_SEPARATOR === '\\' ? '"' : "'"; + $resetCommand->shouldReceive('run')->with(new InputMatcher("--force=1 {$quote}migrate:reset{$quote}"), m::any()); + $migrateCommand->shouldReceive('run')->with(new InputMatcher('--force=1 migrate'), m::any()); + + $this->runCommand($command); + } + + public function testRefreshCommandCallsCommandsWithStep() + { + $command = new RefreshCommand; + + $app = new ApplicationDatabaseRefreshStub(['path.database' => __DIR__]); + $dispatcher = $app->instance(Dispatcher::class, $events = m::mock()); + $console = m::mock(ConsoleApplication::class)->makePartial(); + $console->__construct(); + $command->setLaravel($app); + $command->setApplication($console); + + $rollbackCommand = m::mock(RollbackCommand::class); + $migrateCommand = m::mock(MigrateCommand::class); + + $console->shouldReceive('find')->with('migrate:rollback')->andReturn($rollbackCommand); + $console->shouldReceive('find')->with('migrate')->andReturn($migrateCommand); + $dispatcher->shouldReceive('dispatch')->once()->with(m::type(DatabaseRefreshed::class)); + + $quote = DIRECTORY_SEPARATOR === '\\' ? '"' : "'"; + $rollbackCommand->shouldReceive('run')->with(new InputMatcher("--step=2 --force=1 {$quote}migrate:rollback{$quote}"), m::any()); + $migrateCommand->shouldReceive('run')->with(new InputMatcher('--force=1 migrate'), m::any()); + + $this->runCommand($command, ['--step' => 2]); + } + + public function testRefreshCommandExitsWhenProhibited() + { + $command = new RefreshCommand; + + $app = new ApplicationDatabaseRefreshStub(['path.database' => __DIR__]); + $dispatcher = $app->instance(Dispatcher::class, $events = m::mock()); + $console = m::mock(ConsoleApplication::class)->makePartial(); + $console->__construct(); + $command->setLaravel($app); + $command->setApplication($console); + + RefreshCommand::prohibit(); + + $code = $this->runCommand($command); + + $this->assertSame(1, $code); + + $console->shouldNotHaveBeenCalled(); + $dispatcher->shouldNotReceive('dispatch'); + } + + protected function runCommand($command, $input = []) + { + return $command->run(new ArrayInput($input), new NullOutput); + } +} + +class InputMatcher extends m\Matcher\MatcherAbstract +{ + /** + * @param \Symfony\Component\Console\Input\ArrayInput $actual + * @return bool + */ + public function match(&$actual) + { + return (string) $actual == $this->_expected; + } + + public function __toString() + { + return ''; + } +} + +class ApplicationDatabaseRefreshStub extends Application +{ + public function __construct(array $data = []) + { + foreach ($data as $abstract => $instance) { + $this->instance($abstract, $instance); + } + } + + public function environment(...$environments) + { + return 'development'; + } +} diff --git a/tests/Database/Laravel/DatabaseMigrationRepositoryTest.php b/tests/Database/Laravel/DatabaseMigrationRepositoryTest.php new file mode 100755 index 000000000..d8514cb22 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationRepositoryTest.php @@ -0,0 +1,116 @@ +getRepository(); + $query = m::mock(stdClass::class); + $connectionMock = m::mock(Connection::class); + $repo->getConnectionResolver()->shouldReceive('connection')->with(null)->andReturn($connectionMock); + $repo->getConnection()->shouldReceive('table')->once()->with('migrations')->andReturn($query); + $query->shouldReceive('orderBy')->once()->with('batch', 'asc')->andReturn($query); + $query->shouldReceive('orderBy')->once()->with('migration', 'asc')->andReturn($query); + $query->shouldReceive('pluck')->once()->with('migration')->andReturn(new Collection(['bar'])); + $query->shouldReceive('useWritePdo')->once()->andReturn($query); + + $this->assertEquals(['bar'], $repo->getRan()); + } + + public function testGetLastMigrationsGetsAllMigrationsWithTheLatestBatchNumber() + { + $repo = $this->getMockBuilder(DatabaseMigrationRepository::class)->onlyMethods(['getLastBatchNumber'])->setConstructorArgs([ + $resolver = m::mock(ConnectionResolverInterface::class), 'migrations', + ])->getMock(); + $repo->expects($this->once())->method('getLastBatchNumber')->willReturn(1); + $query = m::mock(stdClass::class); + $connectionMock = m::mock(Connection::class); + $repo->getConnectionResolver()->shouldReceive('connection')->with(null)->andReturn($connectionMock); + $repo->getConnection()->shouldReceive('table')->once()->with('migrations')->andReturn($query); + $query->shouldReceive('where')->once()->with('batch', 1)->andReturn($query); + $query->shouldReceive('orderBy')->once()->with('migration', 'desc')->andReturn($query); + $query->shouldReceive('get')->once()->andReturn(new Collection(['foo'])); + $query->shouldReceive('useWritePdo')->once()->andReturn($query); + + $this->assertEquals(['foo'], $repo->getLast()); + } + + public function testLogMethodInsertsRecordIntoMigrationTable() + { + $repo = $this->getRepository(); + $query = m::mock(stdClass::class); + $connectionMock = m::mock(Connection::class); + $repo->getConnectionResolver()->shouldReceive('connection')->with(null)->andReturn($connectionMock); + $repo->getConnection()->shouldReceive('table')->once()->with('migrations')->andReturn($query); + $query->shouldReceive('insert')->once()->with(['migration' => 'bar', 'batch' => 1]); + $query->shouldReceive('useWritePdo')->once()->andReturn($query); + + $repo->log('bar', 1); + } + + public function testDeleteMethodRemovesAMigrationFromTheTable() + { + $repo = $this->getRepository(); + $query = m::mock(stdClass::class); + $connectionMock = m::mock(Connection::class); + $repo->getConnectionResolver()->shouldReceive('connection')->with(null)->andReturn($connectionMock); + $repo->getConnection()->shouldReceive('table')->once()->with('migrations')->andReturn($query); + $query->shouldReceive('where')->once()->with('migration', 'foo')->andReturn($query); + $query->shouldReceive('delete')->once(); + $query->shouldReceive('useWritePdo')->once()->andReturn($query); + $migration = (object) ['migration' => 'foo']; + + $repo->delete($migration); + } + + public function testGetNextBatchNumberReturnsLastBatchNumberPlusOne() + { + $repo = $this->getMockBuilder(DatabaseMigrationRepository::class)->onlyMethods(['getLastBatchNumber'])->setConstructorArgs([ + m::mock(ConnectionResolverInterface::class), 'migrations', + ])->getMock(); + $repo->expects($this->once())->method('getLastBatchNumber')->willReturn(1); + + $this->assertEquals(2, $repo->getNextBatchNumber()); + } + + public function testGetLastBatchNumberReturnsMaxBatch() + { + $repo = $this->getRepository(); + $query = m::mock(stdClass::class); + $connectionMock = m::mock(Connection::class); + $repo->getConnectionResolver()->shouldReceive('connection')->with(null)->andReturn($connectionMock); + $repo->getConnection()->shouldReceive('table')->once()->with('migrations')->andReturn($query); + $query->shouldReceive('max')->once()->andReturn(1); + $query->shouldReceive('useWritePdo')->once()->andReturn($query); + + $this->assertEquals(1, $repo->getLastBatchNumber()); + } + + public function testCreateRepositoryCreatesProperDatabaseTable() + { + $repo = $this->getRepository(); + $schema = m::mock(stdClass::class); + $connectionMock = m::mock(Connection::class); + $repo->getConnectionResolver()->shouldReceive('connection')->with(null)->andReturn($connectionMock); + $repo->getConnection()->shouldReceive('getSchemaBuilder')->once()->andReturn($schema); + $schema->shouldReceive('create')->once()->with('migrations', m::type(Closure::class)); + + $repo->createRepository(); + } + + protected function getRepository() + { + return new DatabaseMigrationRepository(m::mock(ConnectionResolverInterface::class), 'migrations'); + } +} diff --git a/tests/Database/Laravel/DatabaseMigrationResetCommandTest.php b/tests/Database/Laravel/DatabaseMigrationResetCommandTest.php new file mode 100755 index 000000000..22ebf672b --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationResetCommandTest.php @@ -0,0 +1,93 @@ + __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('usingConnection')->once()->with(null, m::type(Closure::class))->andReturnUsing(function ($connection, $callback) { + $callback(); + }); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('reset')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], false); + + $this->runCommand($command); + } + + public function testResetCommandCanBePretended() + { + $command = new ResetCommand($migrator = m::mock(Migrator::class)); + $app = new ApplicationDatabaseResetStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('usingConnection')->once()->with('foo', m::type(Closure::class))->andReturnUsing(function ($connection, $callback) { + $callback(); + }); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('reset')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], true); + + $this->runCommand($command, ['--pretend' => true, '--database' => 'foo']); + } + + public function testRefreshCommandExitsWhenProhibited() + { + $command = new ResetCommand($migrator = m::mock(Migrator::class)); + + $app = new ApplicationDatabaseResetStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + + ResetCommand::prohibit(); + + $code = $this->runCommand($command); + + $this->assertSame(1, $code); + + $migrator->shouldNotHaveBeenCalled(); + } + + protected function runCommand($command, $input = []) + { + return $command->run(new ArrayInput($input), new NullOutput); + } +} + +class ApplicationDatabaseResetStub extends Application +{ + public function __construct(array $data = []) + { + foreach ($data as $abstract => $instance) { + $this->instance($abstract, $instance); + } + } + + public function environment(...$environments) + { + return 'development'; + } +} diff --git a/tests/Database/Laravel/DatabaseMigrationRollbackCommandTest.php b/tests/Database/Laravel/DatabaseMigrationRollbackCommandTest.php new file mode 100755 index 000000000..946961574 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationRollbackCommandTest.php @@ -0,0 +1,98 @@ + __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('rollback')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => 0, 'batch' => 0]); + + $this->runCommand($command); + } + + public function testRollbackCommandCallsMigratorWithStepOption() + { + $command = new RollbackCommand($migrator = m::mock(Migrator::class)); + $app = new ApplicationDatabaseRollbackStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('rollback')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => false, 'step' => 2, 'batch' => 0]); + + $this->runCommand($command, ['--step' => 2]); + } + + public function testRollbackCommandCanBePretended() + { + $command = new RollbackCommand($migrator = m::mock(Migrator::class)); + $app = new ApplicationDatabaseRollbackStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('rollback')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], true); + + $this->runCommand($command, ['--pretend' => true, '--database' => 'foo']); + } + + public function testRollbackCommandCanBePretendedWithStepOption() + { + $command = new RollbackCommand($migrator = m::mock(Migrator::class)); + $app = new ApplicationDatabaseRollbackStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command->setLaravel($app); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('rollback')->once()->with([__DIR__.DIRECTORY_SEPARATOR.'migrations'], ['pretend' => true, 'step' => 2, 'batch' => 0]); + + $this->runCommand($command, ['--pretend' => true, '--database' => 'foo', '--step' => 2]); + } + + protected function runCommand($command, $input = []) + { + return $command->run(new ArrayInput($input), new NullOutput); + } +} + +class ApplicationDatabaseRollbackStub extends Application +{ + public function __construct(array $data = []) + { + foreach ($data as $abstract => $instance) { + $this->instance($abstract, $instance); + } + } + + public function environment(...$environments) + { + return 'development'; + } +} diff --git a/tests/Database/Laravel/DatabaseMigratorIntegrationTest.php b/tests/Database/Laravel/DatabaseMigratorIntegrationTest.php new file mode 100644 index 000000000..543151719 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigratorIntegrationTest.php @@ -0,0 +1,300 @@ +db = $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'sqlite2'); + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'sqlite3'); + + $db->setAsGlobal(); + + $container = new Container; + $container->instance('db', $db->getDatabaseManager()); + $container->bind('db.schema', function ($app) { + return $app['db']->connection()->getSchemaBuilder(); + }); + + Facade::setFacadeApplication($container); + + $this->migrator = new Migrator( + $repository = new DatabaseMigrationRepository($db->getDatabaseManager(), 'migrations'), + $db->getDatabaseManager(), + new Filesystem + ); + + $output = m::mock(OutputStyle::class); + $output->shouldReceive('write'); + $output->shouldReceive('writeln'); + $output->shouldReceive('newLinesWritten'); + + $this->migrator->setOutput($output); + + if (! $repository->repositoryExists()) { + $repository->createRepository(); + } + + $repository2 = new DatabaseMigrationRepository($db->getDatabaseManager(), 'migrations'); + $repository2->setSource('sqlite2'); + + if (! $repository2->repositoryExists()) { + $repository2->createRepository(); + } + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testBasicMigrationOfSingleFolder() + { + $ran = $this->migrator->run([__DIR__.'/migrations/one']); + + $this->assertTrue($this->db->schema()->hasTable('users')); + $this->assertTrue($this->db->schema()->hasTable('password_resets')); + + $this->assertTrue(str_contains($ran[0], 'users')); + $this->assertTrue(str_contains($ran[1], 'password_resets')); + } + + public function testMigrationsDefaultConnectionCanBeChanged() + { + $ran = $this->migrator->usingConnection('sqlite2', function () { + return $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqllite3']); + }); + + $this->assertFalse($this->db->schema()->hasTable('users')); + $this->assertFalse($this->db->schema()->hasTable('password_resets')); + $this->assertTrue($this->db->schema('sqlite2')->hasTable('users')); + $this->assertTrue($this->db->schema('sqlite2')->hasTable('password_resets')); + $this->assertFalse($this->db->schema('sqlite3')->hasTable('users')); + $this->assertFalse($this->db->schema('sqlite3')->hasTable('password_resets')); + + $this->assertTrue(Str::contains($ran[0], 'users')); + $this->assertTrue(Str::contains($ran[1], 'password_resets')); + } + + public function testMigrationsCanEachDefineConnection() + { + $ran = $this->migrator->run([__DIR__.'/migrations/connection_configured']); + + $this->assertFalse($this->db->schema()->hasTable('failed_jobs')); + $this->assertFalse($this->db->schema()->hasTable('jobs')); + $this->assertFalse($this->db->schema('sqlite2')->hasTable('failed_jobs')); + $this->assertFalse($this->db->schema('sqlite2')->hasTable('jobs')); + $this->assertTrue($this->db->schema('sqlite3')->hasTable('failed_jobs')); + $this->assertTrue($this->db->schema('sqlite3')->hasTable('jobs')); + + $this->assertTrue(Str::contains($ran[0], 'failed_jobs')); + $this->assertTrue(Str::contains($ran[1], 'jobs')); + } + + public function testMigratorCannotChangeDefinedMigrationConnection() + { + $ran = $this->migrator->usingConnection('sqlite2', function () { + return $this->migrator->run([__DIR__.'/migrations/connection_configured']); + }); + + $this->assertFalse($this->db->schema()->hasTable('failed_jobs')); + $this->assertFalse($this->db->schema()->hasTable('jobs')); + $this->assertFalse($this->db->schema('sqlite2')->hasTable('failed_jobs')); + $this->assertFalse($this->db->schema('sqlite2')->hasTable('jobs')); + $this->assertTrue($this->db->schema('sqlite3')->hasTable('failed_jobs')); + $this->assertTrue($this->db->schema('sqlite3')->hasTable('jobs')); + + $this->assertTrue(Str::contains($ran[0], 'failed_jobs')); + $this->assertTrue(Str::contains($ran[1], 'jobs')); + } + + public function testMigrationsCanBeRolledBack() + { + $this->migrator->run([__DIR__.'/migrations/one']); + $this->assertTrue($this->db->schema()->hasTable('users')); + $this->assertTrue($this->db->schema()->hasTable('password_resets')); + $rolledBack = $this->migrator->rollback([__DIR__.'/migrations/one']); + $this->assertFalse($this->db->schema()->hasTable('users')); + $this->assertFalse($this->db->schema()->hasTable('password_resets')); + + $this->assertTrue(str_contains($rolledBack[0], 'password_resets')); + $this->assertTrue(str_contains($rolledBack[1], 'users')); + } + + public function testMigrationsCanBeResetUsingAnString() + { + $this->migrator->run([__DIR__.'/migrations/one']); + $this->assertTrue($this->db->schema()->hasTable('users')); + $this->assertTrue($this->db->schema()->hasTable('password_resets')); + $rolledBack = $this->migrator->reset(__DIR__.'/migrations/one'); + $this->assertFalse($this->db->schema()->hasTable('users')); + $this->assertFalse($this->db->schema()->hasTable('password_resets')); + + $this->assertTrue(str_contains($rolledBack[0], 'password_resets')); + $this->assertTrue(str_contains($rolledBack[1], 'users')); + } + + public function testMigrationsCanBeResetUsingAnArray() + { + $this->migrator->run([__DIR__.'/migrations/one']); + $this->assertTrue($this->db->schema()->hasTable('users')); + $this->assertTrue($this->db->schema()->hasTable('password_resets')); + $rolledBack = $this->migrator->reset([__DIR__.'/migrations/one']); + $this->assertFalse($this->db->schema()->hasTable('users')); + $this->assertFalse($this->db->schema()->hasTable('password_resets')); + + $this->assertTrue(str_contains($rolledBack[0], 'password_resets')); + $this->assertTrue(str_contains($rolledBack[1], 'users')); + } + + public function testNoErrorIsThrownWhenNoOutstandingMigrationsExist() + { + $this->migrator->run([__DIR__.'/migrations/one']); + $this->assertTrue($this->db->schema()->hasTable('users')); + $this->assertTrue($this->db->schema()->hasTable('password_resets')); + $this->migrator->run([__DIR__.'/migrations/one']); + } + + public function testNoErrorIsThrownWhenNothingToRollback() + { + $this->migrator->run([__DIR__.'/migrations/one']); + $this->assertTrue($this->db->schema()->hasTable('users')); + $this->assertTrue($this->db->schema()->hasTable('password_resets')); + $this->migrator->rollback([__DIR__.'/migrations/one']); + $this->assertFalse($this->db->schema()->hasTable('users')); + $this->assertFalse($this->db->schema()->hasTable('password_resets')); + $this->migrator->rollback([__DIR__.'/migrations/one']); + } + + public function testMigrationsCanRunAcrossMultiplePaths() + { + $this->migrator->run([__DIR__.'/migrations/one', __DIR__.'/migrations/two']); + $this->assertTrue($this->db->schema()->hasTable('users')); + $this->assertTrue($this->db->schema()->hasTable('password_resets')); + $this->assertTrue($this->db->schema()->hasTable('flights')); + } + + public function testMigrationsCanBeRolledBackAcrossMultiplePaths() + { + $this->migrator->run([__DIR__.'/migrations/one', __DIR__.'/migrations/two']); + $this->assertTrue($this->db->schema()->hasTable('users')); + $this->assertTrue($this->db->schema()->hasTable('password_resets')); + $this->assertTrue($this->db->schema()->hasTable('flights')); + $this->migrator->rollback([__DIR__.'/migrations/one', __DIR__.'/migrations/two']); + $this->assertFalse($this->db->schema()->hasTable('users')); + $this->assertFalse($this->db->schema()->hasTable('password_resets')); + $this->assertFalse($this->db->schema()->hasTable('flights')); + } + + public function testMigrationsCanBeResetAcrossMultiplePaths() + { + $this->migrator->run([__DIR__.'/migrations/one', __DIR__.'/migrations/two']); + $this->assertTrue($this->db->schema()->hasTable('users')); + $this->assertTrue($this->db->schema()->hasTable('password_resets')); + $this->assertTrue($this->db->schema()->hasTable('flights')); + $this->migrator->reset([__DIR__.'/migrations/one', __DIR__.'/migrations/two']); + $this->assertFalse($this->db->schema()->hasTable('users')); + $this->assertFalse($this->db->schema()->hasTable('password_resets')); + $this->assertFalse($this->db->schema()->hasTable('flights')); + } + + public function testMigrationsCanBeProperlySortedAcrossMultiplePaths() + { + $paths = [__DIR__.'/migrations/multi_path/vendor', __DIR__.'/migrations/multi_path/app']; + + $migrationsFilesFullPaths = array_values($this->migrator->getMigrationFiles($paths)); + + $expected = [ + __DIR__.'/migrations/multi_path/app/2016_01_01_000000_create_users_table.php', // This file was not created on the "vendor" directory on purpose + __DIR__.'/migrations/multi_path/vendor/2016_01_01_200000_create_flights_table.php', // This file was not created on the "app" directory on purpose + __DIR__.'/migrations/multi_path/app/2019_08_08_000001_rename_table_one.php', + __DIR__.'/migrations/multi_path/app/2019_08_08_000002_rename_table_two.php', + __DIR__.'/migrations/multi_path/app/2019_08_08_000003_rename_table_three.php', + __DIR__.'/migrations/multi_path/app/2019_08_08_000004_rename_table_four.php', + __DIR__.'/migrations/multi_path/app/2019_08_08_000005_create_table_one.php', + __DIR__.'/migrations/multi_path/app/2019_08_08_000006_create_table_two.php', + __DIR__.'/migrations/multi_path/vendor/2019_08_08_000007_create_table_three.php', // This file was not created on the "app" directory on purpose + __DIR__.'/migrations/multi_path/app/2019_08_08_000008_create_table_four.php', + ]; + + $this->assertEquals($expected, $migrationsFilesFullPaths); + } + + public function testConnectionPriorToMigrationIsNotChangedAfterMigration() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } + + public function testConnectionPriorToMigrationIsNotChangedAfterRollback() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->rollback([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } + + public function testConnectionPriorToMigrationIsNotChangedWhenNoOutstandingMigrationsExist() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } + + public function testConnectionPriorToMigrationIsNotChangedWhenNothingToRollback() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->rollback([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->rollback([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } + + public function testConnectionPriorToMigrationIsNotChangedAfterMigrateReset() + { + $this->migrator->setConnection('default'); + $this->migrator->run([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->migrator->reset([__DIR__.'/migrations/one'], ['database' => 'sqlite2']); + $this->assertSame('default', $this->migrator->getConnection()); + } +} diff --git a/tests/Database/Laravel/DatabaseMySQLSchemaBuilderTest.php b/tests/Database/Laravel/DatabaseMySQLSchemaBuilderTest.php new file mode 100755 index 000000000..0dba8776f --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySQLSchemaBuilderTest.php @@ -0,0 +1,44 @@ +shouldReceive('getDatabaseName')->andReturn('db'); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $builder = new MySqlBuilder($connection); + $grammar->shouldReceive('compileTableExists')->once()->andReturn('sql'); + $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); + $connection->shouldReceive('scalar')->once()->with('sql')->andReturn(1); + + $this->assertTrue($builder->hasTable('table')); + } + + public function testGetColumnListing() + { + $connection = m::mock(Connection::class); + $grammar = m::mock(MySqlGrammar::class); + $processor = m::mock(MySqlProcessor::class); + $connection->shouldReceive('getDatabaseName')->andReturn('db'); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $grammar->shouldReceive('compileColumns')->with(null, 'prefix_table')->once()->andReturn('sql'); + $processor->shouldReceive('processColumns')->once()->andReturn([['name' => 'column']]); + $builder = new MySqlBuilder($connection); + $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('sql')->andReturn([['name' => 'column']]); + + $this->assertEquals(['column'], $builder->getColumnListing('table')); + } +} diff --git a/tests/Database/Laravel/DatabaseMySqlBuilderTest.php b/tests/Database/Laravel/DatabaseMySqlBuilderTest.php new file mode 100644 index 000000000..f22e27b6c --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlBuilderTest.php @@ -0,0 +1,43 @@ +shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8mb4'); + $connection->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8mb4_unicode_ci'); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'create database `my_temporary_database` default character set `utf8mb4` default collate `utf8mb4_unicode_ci`' + )->andReturn(true); + + $builder = new MySqlBuilder($connection); + $builder->createDatabase('my_temporary_database'); + } + + public function testDropDatabaseIfExists() + { + $connection = m::mock(Connection::class); + $grammar = new MySqlGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'drop database if exists `my_database_a`' + )->andReturn(true); + + $builder = new MySqlBuilder($connection); + + $builder->dropDatabaseIfExists('my_database_a'); + } +} diff --git a/tests/Database/Laravel/DatabaseMySqlProcessorTest.php b/tests/Database/Laravel/DatabaseMySqlProcessorTest.php new file mode 100644 index 000000000..65e37e32c --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlProcessorTest.php @@ -0,0 +1,32 @@ + 'id', 'type_name' => 'bigint', 'type' => 'bigint', 'collation' => 'collate', 'nullable' => 'YES', 'default' => '', 'extra' => 'auto_increment', 'comment' => 'bar', 'expression' => null], + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar(100)', 'collation' => 'collate', 'nullable' => 'NO', 'default' => 'foo', 'extra' => '', 'comment' => '', 'expression' => null], + ['name' => 'email', 'type_name' => 'varchar', 'type' => 'varchar(100)', 'collation' => 'collate', 'nullable' => 'YES', 'default' => 'NULL', 'extra' => 'on update CURRENT_TIMESTAMP', 'comment' => 'NULL', 'expression' => null], + ]; + $expected = [ + ['name' => 'id', 'type_name' => 'bigint', 'type' => 'bigint', 'collation' => 'collate', 'nullable' => true, 'default' => '', 'auto_increment' => true, 'comment' => 'bar', 'generation' => null], + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar(100)', 'collation' => 'collate', 'nullable' => false, 'default' => 'foo', 'auto_increment' => false, 'comment' => null, 'generation' => null], + ['name' => 'email', 'type_name' => 'varchar', 'type' => 'varchar(100)', 'collation' => 'collate', 'nullable' => true, 'default' => 'NULL', 'auto_increment' => false, 'comment' => 'NULL', 'generation' => null], + ]; + $this->assertEquals($expected, $processor->processColumns($listing)); + + // convert listing to objects to simulate PDO::FETCH_CLASS + foreach ($listing as &$row) { + $row = (object) $row; + } + + $this->assertEquals($expected, $processor->processColumns($listing)); + } +} diff --git a/tests/Database/Laravel/DatabaseMySqlQueryGrammarTest.php b/tests/Database/Laravel/DatabaseMySqlQueryGrammarTest.php new file mode 100755 index 000000000..a4220be2b --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlQueryGrammarTest.php @@ -0,0 +1,25 @@ +shouldReceive('escape')->with('foo', false)->andReturn("'foo'"); + $grammar = new MySqlGrammar($connection); + + $query = $grammar->substituteBindingsIntoRawSql( + 'select * from "users" where \'Hello\\\'World?\' IS NOT NULL AND "email" = ?', + ['foo'], + ); + + $this->assertSame('select * from "users" where \'Hello\\\'World?\' IS NOT NULL AND "email" = \'foo\'', $query); + } +} diff --git a/tests/Database/Laravel/DatabaseMySqlSchemaGrammarTest.php b/tests/Database/Laravel/DatabaseMySqlSchemaGrammarTest.php new file mode 100755 index 000000000..289948178 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlSchemaGrammarTest.php @@ -0,0 +1,1751 @@ +getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table `users` add `id` int unsigned not null auto_increment primary key', + 'alter table `users` add `email` varchar(255) not null', + ], $statements); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->uuid('id')->primary(); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table `users` (`id` char(36) not null, primary key (`id`))', $statements[0]); + } + + public function testAutoIncrementStartingValue() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id')->startingValue(1000); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + $this->assertSame('alter table `users` auto_increment = 1000', $statements[1]); + } + + public function testAddColumnsWithMultipleAutoIncrementStartingValue() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id()->from(100); + $blueprint->string('name')->from(200); + $statements = $blueprint->toSql(); + + $this->assertEquals([ + 'alter table `users` add `id` bigint unsigned not null auto_increment primary key', + 'alter table `users` add `name` varchar(255) not null', + 'alter table `users` auto_increment = 100', + ], $statements); + } + + public function testEngineCreateTable() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + $blueprint->engine('InnoDB'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8 collate 'utf8_unicode_ci' engine = InnoDB", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn('InnoDB'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8 collate 'utf8_unicode_ci' engine = InnoDB", $statements[0]); + } + + public function testCharsetCollationCreateTable() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + $blueprint->charset('utf8mb4'); + $blueprint->collation('utf8mb4_unicode_ci'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null) default character set utf8mb4 collate 'utf8mb4_unicode_ci'", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email')->charset('utf8mb4')->collation('utf8mb4_unicode_ci'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) character set utf8mb4 collate 'utf8mb4_unicode_ci' not null) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + } + + public function testBasicCreateTableWithPrefix() + { + $conn = $this->getConnection(prefix: 'prefix_'); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table `prefix_users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null)', $statements[0]); + } + + public function testCreateTemporaryTable() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->temporary(); + $blueprint->increments('id'); + $blueprint->string('email'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create temporary table `users` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null)', $statements[0]); + } + + public function testDropTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->drop(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop table `users`', $statements[0]); + } + + public function testDropTableIfExists() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropIfExists(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop table if exists `users`', $statements[0]); + } + + public function testDropColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `foo`', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn(['foo', 'bar']); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `foo`, drop `bar`', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `foo`, drop `bar`', $statements[0]); + } + + public function testDropPrimary() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropPrimary(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop primary key', $statements[0]); + } + + public function testDropUnique() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropUnique('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop index `foo`', $statements[0]); + } + + public function testDropIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropIndex('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop index `foo`', $statements[0]); + } + + public function testDropSpatialIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->dropSpatialIndex(['coordinates']); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` drop index `geo_coordinates_spatialindex`', $statements[0]); + } + + public function testDropForeign() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropForeign('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop foreign key `foo`', $statements[0]); + } + + public function testDropTimestamps() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropTimestamps(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `created_at`, drop `updated_at`', $statements[0]); + } + + public function testDropTimestampsTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropTimestampsTz(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `created_at`, drop `updated_at`', $statements[0]); + } + + public function testDropMorphs() + { + $blueprint = new Blueprint($this->getConnection(), 'photos'); + $blueprint->dropMorphs('imageable'); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table `photos` drop index `photos_imageable_type_imageable_id_index`', $statements[0]); + $this->assertSame('alter table `photos` drop `imageable_type`, drop `imageable_id`', $statements[1]); + } + + public function testRenameTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rename('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('rename table `users` to `foo`', $statements[0]); + } + + public function testRenameIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->renameIndex('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` rename index `foo` to `bar`', $statements[0]); + } + + public function testAddingPrimaryKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->primary('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add primary key (`foo`)', $statements[0]); + } + + public function testAddingPrimaryKeyWithAlgorithm() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->primary('foo', 'bar', 'hash'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add primary key using hash(`foo`)', $statements[0]); + } + + public function testAddingUniqueKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->unique('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add unique `bar`(`foo`)', $statements[0]); + } + + public function testAddingIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index(['foo', 'bar'], 'baz'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add index `baz`(`foo`, `bar`)', $statements[0]); + } + + public function testAddingIndexWithAlgorithm() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index(['foo', 'bar'], 'baz', 'hash'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add index `baz` using hash(`foo`, `bar`)', $statements[0]); + } + + public function testAddingFulltextIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->fulltext('body'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add fulltext `users_body_fulltext`(`body`)', $statements[0]); + } + + public function testAddingSpatialIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->spatialIndex('coordinates'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add spatial index `geo_coordinates_spatialindex`(`coordinates`)', $statements[0]); + } + + public function testAddingFluentSpatialIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point')->spatialIndex(); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table `geo` add spatial index `geo_coordinates_spatialindex`(`coordinates`)', $statements[1]); + } + + public function testAddingRawIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rawIndex('(function(column))', 'raw_index'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add index `raw_index`((function(column)))', $statements[0]); + } + + public function testAddingForeignKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('foo_id')->references('id')->on('orders'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add constraint `users_foo_id_foreign` foreign key (`foo_id`) references `orders` (`id`)', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('foo_id')->references('id')->on('orders')->cascadeOnDelete(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add constraint `users_foo_id_foreign` foreign key (`foo_id`) references `orders` (`id`) on delete cascade', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('foo_id')->references('id')->on('orders')->cascadeOnUpdate(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add constraint `users_foo_id_foreign` foreign key (`foo_id`) references `orders` (`id`) on update cascade', $statements[0]); + } + + public function testAddingIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->increments('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `id` int unsigned not null auto_increment primary key', $statements[0]); + } + + public function testAddingSmallIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `id` smallint unsigned not null auto_increment primary key', $statements[0]); + } + + public function testAddingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `id` bigint unsigned not null auto_increment primary key', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` bigint unsigned not null auto_increment primary key', $statements[0]); + } + + public function testAddingForeignID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $foreignId = $blueprint->foreignId('foo'); + $blueprint->foreignId('company_id')->constrained(); + $blueprint->foreignId('laravel_idea_id')->constrained(); + $blueprint->foreignId('team_id')->references('id')->on('teams'); + $blueprint->foreignId('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql(); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignId); + $this->assertSame([ + 'alter table `users` add `foo` bigint unsigned not null', + 'alter table `users` add `company_id` bigint unsigned not null', + 'alter table `users` add constraint `users_company_id_foreign` foreign key (`company_id`) references `companies` (`id`)', + 'alter table `users` add `laravel_idea_id` bigint unsigned not null', + 'alter table `users` add constraint `users_laravel_idea_id_foreign` foreign key (`laravel_idea_id`) references `laravel_ideas` (`id`)', + 'alter table `users` add `team_id` bigint unsigned not null', + 'alter table `users` add constraint `users_team_id_foreign` foreign key (`team_id`) references `teams` (`id`)', + 'alter table `users` add `team_column_id` bigint unsigned not null', + 'alter table `users` add constraint `users_team_column_id_foreign` foreign key (`team_column_id`) references `teams` (`id`)', + ], $statements); + } + + public function testAddingForeignIdSpecifyingIndexNameInConstraint() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreignId('company_id')->constrained(indexName: 'my_index'); + $statements = $blueprint->toSql(); + $this->assertSame([ + 'alter table `users` add `company_id` bigint unsigned not null', + 'alter table `users` add constraint `my_index` foreign key (`company_id`) references `companies` (`id`)', + ], $statements); + } + + public function testAddingBigIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `id` bigint unsigned not null auto_increment primary key', $statements[0]); + } + + public function testAddingColumnInTableFirst() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('name')->first(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `name` varchar(255) not null first', $statements[0]); + } + + public function testAddingColumnAfterAnotherColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('name')->after('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `name` varchar(255) not null after `foo`', $statements[0]); + } + + public function testAddingMultipleColumnsAfterAnotherColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->after('foo', function ($blueprint) { + $blueprint->string('one'); + $blueprint->string('two'); + }); + $blueprint->string('three'); + $statements = $blueprint->toSql(); + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `users` add `one` varchar(255) not null after `foo`', + 'alter table `users` add `two` varchar(255) not null after `one`', + 'alter table `users` add `three` varchar(255) not null', + ], $statements); + } + + public function testAddingGeneratedColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'products'); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('price - 5'); + $blueprint->integer('discounted_stored')->storedAs('price - 5'); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `products` add `price` int not null', + 'alter table `products` add `discounted_virtual` int as (price - 5)', + 'alter table `products` add `discounted_stored` int as (price - 5) stored', + ], $statements); + + $blueprint = new Blueprint($this->getConnection(), 'products'); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('price - 5')->nullable(false); + $blueprint->integer('discounted_stored')->storedAs('price - 5')->nullable(false); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `products` add `price` int not null', + 'alter table `products` add `discounted_virtual` int as (price - 5) not null', + 'alter table `products` add `discounted_stored` int as (price - 5) stored not null', + ], $statements); + } + + public function testAddingGeneratedColumnWithCharset() + { + $blueprint = new Blueprint($this->getConnection(), 'links'); + $blueprint->string('url', 2083)->charset('ascii'); + $blueprint->string('url_hash_virtual', 64)->virtualAs('sha2(url, 256)')->charset('ascii'); + $blueprint->string('url_hash_stored', 64)->storedAs('sha2(url, 256)')->charset('ascii'); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `links` add `url` varchar(2083) character set ascii not null', + 'alter table `links` add `url_hash_virtual` varchar(64) character set ascii as (sha2(url, 256))', + 'alter table `links` add `url_hash_stored` varchar(64) character set ascii as (sha2(url, 256)) stored', + ], $statements); + } + + public function testAddingGeneratedColumnByExpression() + { + $blueprint = new Blueprint($this->getConnection(), 'products'); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs(new Expression('price - 5')); + $blueprint->integer('discounted_stored')->storedAs(new Expression('price - 5')); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame([ + 'alter table `products` add `price` int not null', + 'alter table `products` add `discounted_virtual` int as (price - 5)', + 'alter table `products` add `discounted_stored` int as (price - 5) stored', + ], $statements); + } + + public function testAddingInvisibleColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('secret', 64)->nullable(false)->invisible(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `secret` varchar(64) not null invisible', $statements[0]); + } + + public function testAddingString() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(255) not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(100) not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100)->nullable()->default('bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(100) null default \'bar\'', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100)->nullable()->default(new Expression('CURRENT TIMESTAMP')); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(100) null default CURRENT TIMESTAMP', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100)->nullable()->default(Foo::BAR); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(100) null default \'bar\'', $statements[0]); + } + + public function testAddingText() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->text('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` text not null', $statements[0]); + } + + public function testAddingBigInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` bigint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` bigint not null auto_increment primary key', $statements[0]); + } + + public function testAddingInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` int not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` int not null auto_increment primary key', $statements[0]); + } + + public function testAddingIncrementsWithStartingValues() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id()->startingValue(1000); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table `users` add `id` bigint unsigned not null auto_increment primary key', $statements[0]); + $this->assertSame('alter table `users` auto_increment = 1000', $statements[1]); + } + + public function testAddingMediumInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` mediumint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` mediumint not null auto_increment primary key', $statements[0]); + } + + public function testAddingSmallInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` smallint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` smallint not null auto_increment primary key', $statements[0]); + } + + public function testAddingTinyInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->tinyInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` tinyint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->tinyInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` tinyint not null auto_increment primary key', $statements[0]); + } + + public function testAddingFloat() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->float('foo', 5); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` float(5) not null', $statements[0]); + } + + public function testAddingDouble() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->double('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` double not null', $statements[0]); + } + + public function testAddingDecimal() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->decimal('foo', 5, 2); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` decimal(5, 2) not null', $statements[0]); + } + + public function testAddingBoolean() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->boolean('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` tinyint(1) not null', $statements[0]); + } + + public function testAddingEnum() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->enum('role', ['member', 'admin']); + $blueprint->enum('status', Foo::cases()); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table `users` add `role` enum(\'member\', \'admin\') not null', $statements[0]); + $this->assertSame('alter table `users` add `status` enum(\'bar\') not null', $statements[1]); + } + + public function testAddingSet() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->set('role', ['member', 'admin']); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `role` set(\'member\', \'admin\') not null', $statements[0]); + } + + public function testAddingJson() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->json('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` json not null', $statements[0]); + } + + public function testAddingJsonb() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->jsonb('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` json not null', $statements[0]); + } + + public function testAddingDate() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(false); + $conn->shouldReceive('getServerVersion')->andReturn('8.0.13'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->date('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` date not null', $statements[0]); + } + + public function testAddingDateWithDefaultCurrent() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(false); + $conn->shouldReceive('getServerVersion')->andReturn('8.0.13'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->date('foo')->useCurrent(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` date not null default (CURDATE())', $statements[0]); + } + + public function testAddingDateWithDefaultCurrentOn57() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(false); + $conn->shouldReceive('getServerVersion')->andReturn('5.7'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->date('foo')->useCurrent(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` date not null', $statements[0]); + } + + public function testAddingYear() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(false); + $conn->shouldReceive('getServerVersion')->andReturn('8.0.13'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->year('birth_year'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `birth_year` year not null', $statements[0]); + } + + public function testAddingYearWithDefaultCurrent() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(false); + $conn->shouldReceive('getServerVersion')->andReturn('8.0.13'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->year('birth_year')->useCurrent(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `birth_year` year not null default (YEAR(CURDATE()))', $statements[0]); + } + + public function testAddingYearWithDefaultCurrentOn57() + { + $conn = $this->getConnection(); + $conn->shouldReceive('isMaria')->andReturn(false); + $conn->shouldReceive('getServerVersion')->andReturn('5.7'); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->year('birth_year')->useCurrent(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `birth_year` year not null', $statements[0]); + } + + public function testAddingDateTime() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime(1) not null', $statements[0]); + } + + public function testAddingDateTimeWithDefaultCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo')->useCurrent(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null default CURRENT_TIMESTAMP', $statements[0]); + } + + public function testAddingDateTimeWithOnUpdateCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo')->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null on update CURRENT_TIMESTAMP', $statements[0]); + } + + public function testAddingDateTimeWithDefaultCurrentAndOnUpdateCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo')->useCurrent()->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP', $statements[0]); + } + + public function testAddingDateTimeWithDefaultCurrentOnUpdateCurrentAndPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('foo', 3)->useCurrent()->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime(3) not null default CURRENT_TIMESTAMP(3) on update CURRENT_TIMESTAMP(3)', $statements[0]); + } + + public function testAddingDateTimeTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTimeTz('foo', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime(1) not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTimeTz('foo'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` datetime not null', $statements[0]); + } + + public function testAddingTime() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->time('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` time not null', $statements[0]); + } + + public function testAddingTimeWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->time('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` time(1) not null', $statements[0]); + } + + public function testAddingTimeTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timeTz('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` time not null', $statements[0]); + } + + public function testAddingTimeTzWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timeTz('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` time(1) not null', $statements[0]); + } + + public function testAddingTimestamp() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp not null', $statements[0]); + } + + public function testAddingTimestampWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null', $statements[0]); + } + + public function testAddingTimestampWithDefault() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at')->default('2015-07-22 11:43:17'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame("alter table `users` add `created_at` timestamp not null default '2015-07-22 11:43:17'", $statements[0]); + } + + public function testAddingTimestampWithDefaultCurrentSpecifyingPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at', 1)->useCurrent(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null default CURRENT_TIMESTAMP(1)', $statements[0]); + } + + public function testAddingTimestampWithOnUpdateCurrentSpecifyingPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at', 1)->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null on update CURRENT_TIMESTAMP(1)', $statements[0]); + } + + public function testAddingTimestampWithDefaultCurrentAndOnUpdateCurrentSpecifyingPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at', 1)->useCurrent()->useCurrentOnUpdate(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null default CURRENT_TIMESTAMP(1) on update CURRENT_TIMESTAMP(1)', $statements[0]); + } + + public function testAddingTimestampTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampTz('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp not null', $statements[0]); + } + + public function testAddingTimestampTzWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampTz('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `created_at` timestamp(1) not null', $statements[0]); + } + + public function testAddingTimeStampTzWithDefault() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampTz('created_at')->default('2015-07-22 11:43:17'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame("alter table `users` add `created_at` timestamp not null default '2015-07-22 11:43:17'", $statements[0]); + } + + public function testAddingTimestamps() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamps(); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table `users` add `created_at` timestamp null', + 'alter table `users` add `updated_at` timestamp null', + ], $statements); + } + + public function testAddingTimestampsTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampsTz(); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table `users` add `created_at` timestamp null', + 'alter table `users` add `updated_at` timestamp null', + ], $statements); + } + + public function testAddingRememberToken() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rememberToken(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `remember_token` varchar(100) null', $statements[0]); + } + + public function testAddingBinary() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->binary('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` blob not null', $statements[0]); + } + + public function testAddingUuid() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->uuid('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` char(36) not null', $statements[0]); + } + + public function testAddingUuidDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->uuid(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `uuid` char(36) not null', $statements[0]); + } + + public function testAddingForeignUuid() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $foreignUuid = $blueprint->foreignUuid('foo'); + $blueprint->foreignUuid('company_id')->constrained(); + $blueprint->foreignUuid('laravel_idea_id')->constrained(); + $blueprint->foreignUuid('team_id')->references('id')->on('teams'); + $blueprint->foreignUuid('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql(); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignUuid); + $this->assertSame([ + 'alter table `users` add `foo` char(36) not null', + 'alter table `users` add `company_id` char(36) not null', + 'alter table `users` add constraint `users_company_id_foreign` foreign key (`company_id`) references `companies` (`id`)', + 'alter table `users` add `laravel_idea_id` char(36) not null', + 'alter table `users` add constraint `users_laravel_idea_id_foreign` foreign key (`laravel_idea_id`) references `laravel_ideas` (`id`)', + 'alter table `users` add `team_id` char(36) not null', + 'alter table `users` add constraint `users_team_id_foreign` foreign key (`team_id`) references `teams` (`id`)', + 'alter table `users` add `team_column_id` char(36) not null', + 'alter table `users` add constraint `users_team_column_id_foreign` foreign key (`team_column_id`) references `teams` (`id`)', + ], $statements); + } + + public function testAddingIpAddress() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->ipAddress('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(45) not null', $statements[0]); + } + + public function testAddingIpAddressDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->ipAddress(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `ip_address` varchar(45) not null', $statements[0]); + } + + public function testAddingMacAddress() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->macAddress('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `foo` varchar(17) not null', $statements[0]); + } + + public function testAddingMacAddressDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->macAddress(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `mac_address` varchar(17) not null', $statements[0]); + } + + public function testAddingGeometry() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` geometry not null', $statements[0]); + } + + public function testAddingGeography() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geography('coordinates'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` geometry srid 4326 not null', $statements[0]); + } + + public function testAddingPoint() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` point not null', $statements[0]); + } + + public function testAddingPointWithSrid() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point', 4326); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` point srid 4326 not null', $statements[0]); + } + + public function testAddingPointWithSridColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point', 4326)->after('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` point srid 4326 not null after `id`', $statements[0]); + } + + public function testAddingLineString() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'linestring'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` linestring not null', $statements[0]); + } + + public function testAddingPolygon() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'polygon'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` polygon not null', $statements[0]); + } + + public function testAddingGeometryCollection() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'geometrycollection'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` geometrycollection not null', $statements[0]); + } + + public function testAddingMultiPoint() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'multipoint'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` multipoint not null', $statements[0]); + } + + public function testAddingMultiLineString() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'multilinestring'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` multilinestring not null', $statements[0]); + } + + public function testAddingMultiPolygon() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'multipolygon'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `geo` add `coordinates` multipolygon not null', $statements[0]); + } + + public function testAddingComment() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo')->comment("Escape ' when using words like it's"); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("alter table `users` add `foo` varchar(255) not null comment 'Escape \\' when using words like it\\'s'", $statements[0]); + } + + public function testAddingVector() + { + $blueprint = new Blueprint($this->getConnection(), 'embeddings'); + $blueprint->vector('embedding', 384); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `embeddings` add `embedding` vector(384) not null', $statements[0]); + } + + public function testCreateDatabase() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8mb4_foo'); + $connection->shouldReceive('getConfig')->once()->once()->with('collation')->andReturn('utf8mb4_unicode_ci_foo'); + + $statement = $this->getGrammar($connection)->compileCreateDatabase('my_database_a'); + + $this->assertSame( + 'create database `my_database_a` default character set `utf8mb4_foo` default collate `utf8mb4_unicode_ci_foo`', + $statement + ); + + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8mb4_bar'); + $connection->shouldReceive('getConfig')->once()->once()->with('collation')->andReturn('utf8mb4_unicode_ci_bar'); + + $statement = $this->getGrammar($connection)->compileCreateDatabase('my_database_b'); + + $this->assertSame( + 'create database `my_database_b` default character set `utf8mb4_bar` default collate `utf8mb4_unicode_ci_bar`', + $statement + ); + } + + public function testCreateTableWithVirtualAsColumn() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->virtualAs('my_column'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_column` varchar(255) not null, `my_other_column` varchar(255) as (my_column)) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAsJson('my_json_column->some_attribute'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"some_attribute\"'))))", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAsJson('my_json_column->some_attribute->nested'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"some_attribute\".\"nested\"'))))", $statements[0]); + } + + public function testCreateTableWithVirtualAsColumnWhenJsonColumnHasArrayKey() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column')->virtualAsJson('my_json_column->foo[0][1]'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"foo\"[0][1]'))))", $statements[0]); + } + + public function testCreateTableWithStoredAsColumn() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->storedAs('my_column'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_column` varchar(255) not null, `my_other_column` varchar(255) as (my_column) stored) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAsJson('my_json_column->some_attribute'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"some_attribute\"'))) stored)", $statements[0]); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAsJson('my_json_column->some_attribute->nested'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"some_attribute\".\"nested\"'))) stored)", $statements[0]); + } + + public function testDropDatabaseIfExists() + { + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_a'); + + $this->assertSame( + 'drop database if exists `my_database_a`', + $statement + ); + + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_b'); + + $this->assertSame( + 'drop database if exists `my_database_b`', + $statement + ); + } + + public function testDropAllTables() + { + $statement = $this->getGrammar()->compileDropAllTables(['alpha', 'beta', 'gamma']); + + $this->assertSame('drop table `alpha`, `beta`, `gamma`', $statement); + } + + public function testDropAllViews() + { + $statement = $this->getGrammar()->compileDropAllViews(['alpha', 'beta', 'gamma']); + + $this->assertSame('drop view `alpha`, `beta`, `gamma`', $statement); + } + + public function testDropAllTablesWithPrefixAndSchema() + { + $connection = $this->getConnection(prefix: 'prefix_'); + $statement = $this->getGrammar($connection)->compileDropAllTables(['schema.alpha', 'schema.beta', 'schema.gamma']); + + $this->assertSame('drop table `schema`.`alpha`, `schema`.`beta`, `schema`.`gamma`', $statement); + } + + public function testDropAllViewsWithPrefixAndSchema() + { + $connection = $this->getConnection(prefix: 'prefix_'); + $statement = $this->getGrammar($connection)->compileDropAllViews(['schema.alpha', 'schema.beta', 'schema.gamma']); + + $this->assertSame('drop view `schema`.`alpha`, `schema`.`beta`, `schema`.`gamma`', $statement); + } + + public function testGrammarsAreMacroable() + { + // compileReplace macro. + $this->getGrammar()::macro('compileReplace', function () { + return true; + }); + + $c = $this->getGrammar()::compileReplace(); + + $this->assertTrue($c); + } + + protected function getConnection( + ?MySqlGrammar $grammar = null, + ?MySqlBuilder $builder = null, + string $prefix = '' + ) { + $connection = m::mock(Connection::class) + ->shouldReceive('getTablePrefix')->andReturn($prefix) + ->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(null) + ->shouldReceive('isMaria')->andReturn(false) + ->getMock(); + + $grammar ??= $this->getGrammar($connection); + $builder ??= $this->getBuilder(); + + return $connection + ->shouldReceive('getSchemaGrammar')->andReturn($grammar) + ->shouldReceive('getSchemaBuilder')->andReturn($builder) + ->getMock(); + } + + public function testAddingColumnWithAlgorithm() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('name')->instant(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `name` varchar(255) not null, algorithm=instant', $statements[0]); + } + + public function testChangingColumnWithAlgorithm() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('name', 100)->change()->instant(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` modify `name` varchar(100) not null, algorithm=instant', $statements[0]); + } + + public function testDroppingColumnWithAlgorithm() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn('name')->instant(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `name`, algorithm=instant', $statements[0]); + } + + public function testAddingColumnWithLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('name')->lock('none'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `name` varchar(255) not null, lock=none', $statements[0]); + } + + public function testChangingColumnWithLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('name', 100)->change()->lock('none'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` modify `name` varchar(100) not null, lock=none', $statements[0]); + } + + public function testDroppingColumnWithLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn('name')->lock('none'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` drop `name`, lock=none', $statements[0]); + } + + public function testColumnWithBothAlgorithmAndLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('name')->instant()->lock('none'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add `name` varchar(255) not null, algorithm=instant, lock=none', $statements[0]); + } + + public function testAddingIndexWithLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index('name')->lock('none'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add index `users_name_index`(`name`), lock=none', $statements[0]); + } + + public function testAddingUniqueIndexWithLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->unique('email')->lock('shared'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add unique `users_email_unique`(`email`), lock=shared', $statements[0]); + } + + public function testAddingPrimaryKeyWithLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->primary('id')->lock('exclusive'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add primary key (`id`), lock=exclusive', $statements[0]); + } + + public function testAddingForeignKeyWithLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('user_id')->references('id')->on('accounts')->lock('none'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add constraint `users_user_id_foreign` foreign key (`user_id`) references `accounts` (`id`), lock=none', $statements[0]); + } + + public function testAddingFullTextIndexWithLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->fullText('content')->lock('shared'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add fulltext `users_content_fulltext`(`content`), lock=shared', $statements[0]); + } + + public function testAddingSpatialIndexWithLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->spatialIndex('location')->lock('default'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add spatial index `users_location_spatialindex`(`location`), lock=default', $statements[0]); + } + + public function testIndexWithAlgorithmAndLock() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index('name', 'custom_idx')->algorithm('btree')->lock('none'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table `users` add index `custom_idx` using btree(`name`), lock=none', $statements[0]); + } + + public function getGrammar(?Connection $connection = null) + { + return new MySqlGrammar($connection ?? $this->getConnection()); + } + + public function getBuilder() + { + return mock(MySqlBuilder::class); + } +} diff --git a/tests/Database/Laravel/DatabaseMySqlSchemaStateTest.php b/tests/Database/Laravel/DatabaseMySqlSchemaStateTest.php new file mode 100644 index 000000000..9d60b0e4a --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlSchemaStateTest.php @@ -0,0 +1,133 @@ +createMock(MySqlConnection::class); + $connection->method('getConfig')->willReturn($dbConfig); + + $schemaState = new MySqlSchemaState($connection); + + // test connectionString + $method = new ReflectionMethod(get_class($schemaState), 'connectionString'); + $connString = $method->invoke($schemaState); + + self::assertEquals($expectedConnectionString, $connString); + + // test baseVariables + $method = new ReflectionMethod(get_class($schemaState), 'baseVariables'); + $variables = $method->invoke($schemaState, $dbConfig); + + self::assertEquals($expectedVariables, $variables); + } + + public static function provider(): Generator + { + yield 'default' => [ + ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}" --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}"', [ + 'LARAVEL_LOAD_SOCKET' => '', + 'LARAVEL_LOAD_HOST' => '127.0.0.1', + 'LARAVEL_LOAD_PORT' => '', + 'LARAVEL_LOAD_USER' => 'root', + 'LARAVEL_LOAD_PASSWORD' => '', + 'LARAVEL_LOAD_DATABASE' => 'forge', + 'LARAVEL_LOAD_SSL_CA' => '', + ], [ + 'username' => 'root', + 'host' => '127.0.0.1', + 'database' => 'forge', + ], + ]; + + yield 'ssl_ca' => [ + ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}" --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --ssl-ca="${:LARAVEL_LOAD_SSL_CA}"', [ + 'LARAVEL_LOAD_SOCKET' => '', + 'LARAVEL_LOAD_HOST' => '', + 'LARAVEL_LOAD_PORT' => '', + 'LARAVEL_LOAD_USER' => 'root', + 'LARAVEL_LOAD_PASSWORD' => '', + 'LARAVEL_LOAD_DATABASE' => 'forge', + 'LARAVEL_LOAD_SSL_CA' => 'ssl.ca', + ], [ + 'username' => 'root', + 'database' => 'forge', + 'options' => [ + PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA => 'ssl.ca', + ], + ], + ]; + + // yield 'no_ssl' => [ + // ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}" --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --ssl=off', [ + // 'LARAVEL_LOAD_SOCKET' => '', + // 'LARAVEL_LOAD_HOST' => '', + // 'LARAVEL_LOAD_PORT' => '', + // 'LARAVEL_LOAD_USER' => 'root', + // 'LARAVEL_LOAD_PASSWORD' => '', + // 'LARAVEL_LOAD_DATABASE' => 'forge', + // 'LARAVEL_LOAD_SSL_CA' => '', + // ], [ + // 'username' => 'root', + // 'database' => 'forge', + // 'options' => [ + // \PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false, + // ], + // ], + // ]; + + yield 'unix socket' => [ + ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}" --socket="${:LARAVEL_LOAD_SOCKET}"', [ + 'LARAVEL_LOAD_SOCKET' => '/tmp/mysql.sock', + 'LARAVEL_LOAD_HOST' => '', + 'LARAVEL_LOAD_PORT' => '', + 'LARAVEL_LOAD_USER' => 'root', + 'LARAVEL_LOAD_PASSWORD' => '', + 'LARAVEL_LOAD_DATABASE' => 'forge', + 'LARAVEL_LOAD_SSL_CA' => '', + ], [ + 'username' => 'root', + 'database' => 'forge', + 'unix_socket' => '/tmp/mysql.sock', + ], + ]; + } + + public function testExecuteDumpProcessForDepth() + { + $mockProcess = $this->createMock(Process::class); + $mockProcess->method('setTimeout')->willReturnSelf(); + $mockProcess->method('mustRun')->will( + $this->throwException(new Exception('column-statistics')) + ); + + $mockOutput = $this->createMock(\stdClass::class); + $mockVariables = []; + + $schemaState = $this->getMockBuilder(MySqlSchemaState::class) + ->disableOriginalConstructor() + ->onlyMethods(['makeProcess']) + ->getMock(); + + $schemaState->method('makeProcess')->willReturn($mockProcess); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Dump execution exceeded maximum depth of 30.'); + + // test executeDumpProcess + $method = new ReflectionMethod(get_class($schemaState), 'executeDumpProcess'); + $method->invoke($schemaState, $mockProcess, $mockOutput, $mockVariables, 31); + } +} diff --git a/tests/Database/Laravel/DatabasePostgresBuilderTest.php b/tests/Database/Laravel/DatabasePostgresBuilderTest.php new file mode 100644 index 000000000..8804f0369 --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresBuilderTest.php @@ -0,0 +1,294 @@ +shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'create database "my_temporary_database" encoding "utf8"' + )->andReturn(true); + + $builder = $this->getBuilder($connection); + $builder->createDatabase('my_temporary_database'); + } + + public function testDropDatabaseIfExists() + { + $connection = m::mock(Connection::class); + $grammar = new PostgresGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('statement')->once()->with( + 'drop database if exists "my_database_a"' + )->andReturn(true); + + $builder = $this->getBuilder($connection); + + $builder->dropDatabaseIfExists('my_database_a'); + } + + public function testHasTableWhenSchemaUnqualifiedAndSearchPathMissing() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn(null); + $connection->shouldReceive('getConfig')->with('schema')->andReturn(null); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('compileTableExists')->andReturn('sql'); + $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('getTablePrefix'); + $builder = $this->getBuilder($connection); + + $this->assertTrue($builder->hasTable('foo')); + $this->assertTrue($builder->hasTable('public.foo')); + } + + public function testHasTableWhenSchemaUnqualifiedAndSearchPathFilled() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn('myapp,public'); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('compileTableExists')->andReturn('sql'); + $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('getTablePrefix'); + $builder = $this->getBuilder($connection); + + $this->assertTrue($builder->hasTable('foo')); + $this->assertTrue($builder->hasTable('myapp.foo')); + } + + public function testHasTableWhenSchemaUnqualifiedAndSearchPathFallbackFilled() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn(null); + $connection->shouldReceive('getConfig')->with('schema')->andReturn(['myapp', 'public']); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('compileTableExists')->andReturn('sql'); + $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('getTablePrefix'); + $builder = $this->getBuilder($connection); + + $this->assertTrue($builder->hasTable('foo')); + $this->assertTrue($builder->hasTable('myapp.foo')); + } + + public function testHasTableWhenSchemaUnqualifiedAndSearchPathIsUserVariable() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('username')->andReturn('foouser'); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn('$user'); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('compileTableExists')->andReturn('sql'); + $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('getTablePrefix'); + $builder = $this->getBuilder($connection); + + $this->assertTrue($builder->hasTable('foo')); + $this->assertTrue($builder->hasTable('foouser.foo')); + } + + public function testHasTableWhenSchemaQualifiedAndSearchPathMismatches() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn('public'); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('compileTableExists')->andReturn('sql'); + $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('getTablePrefix'); + $builder = $this->getBuilder($connection); + + $this->assertTrue($builder->hasTable('myapp.foo')); + } + + public function testHasTableWhenDatabaseAndSchemaQualifiedAndSearchPathMismatches() + { + $this->expectException(\InvalidArgumentException::class); + + $connection = $this->getConnection(); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $builder = $this->getBuilder($connection); + + $builder->hasTable('mydatabase.myapp.foo'); + } + + public function testGetColumnListingWhenSchemaUnqualifiedAndSearchPathMissing() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn(null); + $connection->shouldReceive('getConfig')->with('schema')->andReturn(null); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('compileColumns')->with(null, 'foo')->andReturn('sql'); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'some_column']]); + $connection->shouldReceive('getTablePrefix'); + $processor = m::mock(PostgresProcessor::class); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $processor->shouldReceive('processColumns')->andReturn([['name' => 'some_column']]); + $builder = $this->getBuilder($connection); + + $builder->getColumnListing('foo'); + } + + public function testGetColumnListingWhenSchemaUnqualifiedAndSearchPathFilled() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn('myapp,public'); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('compileColumns')->with(null, 'foo')->andReturn('sql'); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'some_column']]); + $connection->shouldReceive('getTablePrefix'); + $processor = m::mock(PostgresProcessor::class); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $processor->shouldReceive('processColumns')->andReturn([['name' => 'some_column']]); + $builder = $this->getBuilder($connection); + + $builder->getColumnListing('foo'); + } + + public function testGetColumnListingWhenSchemaUnqualifiedAndSearchPathIsUserVariable() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('username')->andReturn('foouser'); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn('$user'); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('compileColumns')->with(null, 'foo')->andReturn('sql'); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'some_column']]); + $connection->shouldReceive('getTablePrefix'); + $processor = m::mock(PostgresProcessor::class); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $processor->shouldReceive('processColumns')->andReturn([['name' => 'some_column']]); + $builder = $this->getBuilder($connection); + + $builder->getColumnListing('foo'); + } + + public function testGetColumnListingWhenSchemaQualifiedAndSearchPathMismatches() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn('public'); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('compileColumns')->with('myapp', 'foo')->andReturn('sql'); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'some_column']]); + $connection->shouldReceive('getTablePrefix'); + $processor = m::mock(PostgresProcessor::class); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $processor->shouldReceive('processColumns')->andReturn([['name' => 'some_column']]); + $builder = $this->getBuilder($connection); + + $builder->getColumnListing('myapp.foo'); + } + + public function testGetColumnWhenDatabaseAndSchemaQualifiedAndSearchPathMismatches() + { + $this->expectException(\InvalidArgumentException::class); + + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn('public'); + $grammar = m::mock(PostgresGrammar::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $builder = $this->getBuilder($connection); + + $builder->getColumnListing('mydatabase.myapp.foo'); + } + + public function testDropAllTablesWhenSearchPathIsString() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn('public'); + $connection->shouldReceive('getConfig')->with('dont_drop')->andReturn(['foo']); + $grammar = m::mock(PostgresGrammar::class); + $processor = m::mock(PostgresProcessor::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $grammar->shouldReceive('compileTables')->andReturn('sql'); + $processor->shouldReceive('processTables')->once()->andReturn([['name' => 'users', 'schema' => 'public', 'schema_qualified_name' => 'public.users']]); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'users', 'schema' => 'public', 'schema_qualified_name' => 'public.users']]); + $grammar->shouldReceive('compileDropAllTables')->with(['public.users'])->andReturn('drop table "public"."users" cascade'); + $connection->shouldReceive('statement')->with('drop table "public"."users" cascade'); + $builder = $this->getBuilder($connection); + + $builder->dropAllTables(); + } + + public function testDropAllTablesWhenSearchPathIsStringOfMany() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('username')->andReturn('foouser'); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn('"$user", public, foo_bar-Baz.Áüõß'); + $connection->shouldReceive('getConfig')->with('dont_drop')->andReturn(['foo']); + $grammar = m::mock(PostgresGrammar::class); + $processor = m::mock(PostgresProcessor::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $processor->shouldReceive('processTables')->once()->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users']]); + $grammar->shouldReceive('compileTables')->andReturn('sql'); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users']]); + $grammar->shouldReceive('compileDropAllTables')->with(['foouser.users'])->andReturn('drop table "foouser"."users" cascade'); + $connection->shouldReceive('statement')->with('drop table "foouser"."users" cascade'); + $builder = $this->getBuilder($connection); + + $builder->dropAllTables(); + } + + public function testDropAllTablesWhenSearchPathIsArrayOfMany() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->with('username')->andReturn('foouser'); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn([ + '$user', + '"dev"', + "'test'", + 'spaced schema', + ]); + $connection->shouldReceive('getConfig')->with('dont_drop')->andReturn(['foo']); + $grammar = m::mock(PostgresGrammar::class); + $processor = m::mock(PostgresProcessor::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $processor->shouldReceive('processTables')->once()->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users']]); + $grammar->shouldReceive('compileTables')->andReturn('sql'); + $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users']]); + $grammar->shouldReceive('compileDropAllTables')->with(['foouser.users'])->andReturn('drop table "foouser"."users" cascade'); + $connection->shouldReceive('statement')->with('drop table "foouser"."users" cascade'); + $builder = $this->getBuilder($connection); + + $builder->dropAllTables(); + } + + protected function getConnection() + { + return m::mock(Connection::class); + } + + protected function getBuilder($connection) + { + return new PostgresBuilder($connection); + } + + protected function getGrammar() + { + return new PostgresGrammar; + } +} diff --git a/tests/Database/Laravel/DatabasePostgresProcessorTest.php b/tests/Database/Laravel/DatabasePostgresProcessorTest.php new file mode 100644 index 000000000..76c3e733e --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresProcessorTest.php @@ -0,0 +1,36 @@ + 'id', 'type_name' => 'int4', 'type' => 'integer', 'collation' => '', 'nullable' => true, 'default' => "nextval('employee_id_seq'::regclass)", 'comment' => '', 'generated' => false], + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'character varying(100)', 'collation' => 'collate', 'nullable' => false, 'default' => '', 'comment' => 'foo', 'generated' => false], + ['name' => 'balance', 'type_name' => 'numeric', 'type' => 'numeric(8,2)', 'collation' => '', 'nullable' => true, 'default' => '4', 'comment' => 'NULL', 'generated' => false], + ['name' => 'birth_date', 'type_name' => 'timestamp', 'type' => 'timestamp(6) without time zone', 'collation' => '', 'nullable' => false, 'default' => '', 'comment' => '', 'generated' => false], + ]; + $expected = [ + ['name' => 'id', 'type_name' => 'int4', 'type' => 'integer', 'collation' => '', 'nullable' => true, 'default' => "nextval('employee_id_seq'::regclass)", 'auto_increment' => true, 'comment' => '', 'generation' => null], + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'character varying(100)', 'collation' => 'collate', 'nullable' => false, 'default' => '', 'auto_increment' => false, 'comment' => 'foo', 'generation' => null], + ['name' => 'balance', 'type_name' => 'numeric', 'type' => 'numeric(8,2)', 'collation' => '', 'nullable' => true, 'default' => '4', 'auto_increment' => false, 'comment' => 'NULL', 'generation' => null], + ['name' => 'birth_date', 'type_name' => 'timestamp', 'type' => 'timestamp(6) without time zone', 'collation' => '', 'nullable' => false, 'default' => '', 'auto_increment' => false, 'comment' => '', 'generation' => null], + ]; + + $this->assertEquals($expected, $processor->processColumns($listing)); + + // convert listing to objects to simulate PDO::FETCH_CLASS + foreach ($listing as &$row) { + $row = (object) $row; + } + + $this->assertEquals($expected, $processor->processColumns($listing)); + } +} diff --git a/tests/Database/Laravel/DatabasePostgresQueryGrammarTest.php b/tests/Database/Laravel/DatabasePostgresQueryGrammarTest.php new file mode 100755 index 000000000..700a8715f --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresQueryGrammarTest.php @@ -0,0 +1,70 @@ +shouldReceive('escape')->with('foo', false)->andReturn("'foo'"); + $grammar = new PostgresGrammar($connection); + + $query = $grammar->substituteBindingsIntoRawSql( + 'select * from "users" where \'{}\' ?? \'Hello\\\'\\\'World?\' AND "email" = ?', + ['foo'], + ); + + $this->assertSame('select * from "users" where \'{}\' ? \'Hello\\\'\\\'World?\' AND "email" = \'foo\'', $query); + } + + public function testCustomOperators() + { + PostgresGrammar::customOperators(['@@@', '@>', '']); + PostgresGrammar::customOperators(['@@>', 1]); + + $connection = m::mock(Connection::class); + $grammar = new PostgresGrammar($connection); + + $operators = $grammar->getOperators(); + + $this->assertIsList($operators); + $this->assertContains('@@@', $operators); + $this->assertContains('@@>', $operators); + $this->assertNotContains('', $operators); + $this->assertNotContains(1, $operators); + $this->assertSame(array_unique($operators), $operators); + } + + public function testCompileTruncate() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + + $postgres = new PostgresGrammar($connection); + $builder = m::mock(Builder::class); + $builder->from = 'users'; + + $this->assertEquals([ + 'truncate "users" restart identity cascade' => [], + ], $postgres->compileTruncate($builder)); + + PostgresGrammar::cascadeOnTruncate(false); + + $this->assertEquals([ + 'truncate "users" restart identity' => [], + ], $postgres->compileTruncate($builder)); + + PostgresGrammar::cascadeOnTruncate(); + + $this->assertEquals([ + 'truncate "users" restart identity cascade' => [], + ], $postgres->compileTruncate($builder)); + } +} diff --git a/tests/Database/Laravel/DatabasePostgresSchemaBuilderTest.php b/tests/Database/Laravel/DatabasePostgresSchemaBuilderTest.php new file mode 100755 index 000000000..efd0fd257 --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresSchemaBuilderTest.php @@ -0,0 +1,43 @@ +shouldReceive('getSchemaGrammar')->andReturn($grammar); + $builder = new PostgresBuilder($connection); + $grammar->shouldReceive('compileTableExists')->twice()->andReturn('sql'); + $connection->shouldReceive('getTablePrefix')->twice()->andReturn('prefix_'); + $connection->shouldReceive('scalar')->twice()->with('sql')->andReturn(1); + + $this->assertTrue($builder->hasTable('table')); + $this->assertTrue($builder->hasTable('public.table')); + } + + public function testGetColumnListing() + { + $connection = m::mock(Connection::class); + $grammar = m::mock(PostgresGrammar::class); + $processor = m::mock(PostgresProcessor::class); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $grammar->shouldReceive('compileColumns')->with(null, 'prefix_table')->once()->andReturn('sql'); + $processor->shouldReceive('processColumns')->once()->andReturn([['name' => 'column']]); + $builder = new PostgresBuilder($connection); + $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('sql')->andReturn([['name' => 'column']]); + + $this->assertEquals(['column'], $builder->getColumnListing('table')); + } +} diff --git a/tests/Database/Laravel/DatabasePostgresSchemaGrammarTest.php b/tests/Database/Laravel/DatabasePostgresSchemaGrammarTest.php new file mode 100755 index 000000000..03bd61315 --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresSchemaGrammarTest.php @@ -0,0 +1,1391 @@ +getConnection(), 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + $blueprint->string('name')->collation('nb_NO.utf8'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("id" serial not null primary key, "email" varchar(255) not null, "name" varchar(255) collate "nb_NO.utf8" not null)', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->increments('id'); + $blueprint->string('email'); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table "users" add column "id" serial not null primary key', + 'alter table "users" add column "email" varchar(255) not null', + ], $statements); + } + + public function testAddingVector() + { + $blueprint = new Blueprint($this->getConnection(), 'embeddings'); + $blueprint->vector('embedding', 384); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "embeddings" add column "embedding" vector(384) not null', $statements[0]); + } + + public function testCreateTableWithAutoIncrementStartingValue() + { + $connection = $this->getConnection(); + $connection->getSchemaBuilder()->shouldReceive('parseSchemaAndTable')->andReturn([null, 'users']); + + $blueprint = new Blueprint($connection, 'users'); + $blueprint->create(); + $blueprint->increments('id')->startingValue(1000); + $blueprint->string('email'); + $blueprint->string('name')->collation('nb_NO.utf8'); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('create table "users" ("id" serial not null primary key, "email" varchar(255) not null, "name" varchar(255) collate "nb_NO.utf8" not null)', $statements[0]); + $this->assertSame('alter sequence users_id_seq restart with 1000', $statements[1]); + } + + public function testAddColumnsWithMultipleAutoIncrementStartingValue() + { + $builder = $this->getBuilder(); + $builder->shouldReceive('parseSchemaAndTable')->andReturn([null, 'users']); + + $blueprint = new Blueprint($this->getConnection(builder: $builder), 'users'); + $blueprint->id()->from(100); + $blueprint->increments('code')->from(200); + $blueprint->string('name')->from(300); + $statements = $blueprint->toSql(); + + $this->assertEquals([ + 'alter table "users" add column "id" bigserial not null primary key', + 'alter table "users" add column "code" serial not null primary key', + 'alter table "users" add column "name" varchar(255) not null', + 'alter sequence users_id_seq restart with 100', + 'alter sequence users_code_seq restart with 200', + ], $statements); + } + + public function testCreateTableAndCommentColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email')->comment('my first comment'); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('create table "users" ("id" serial not null primary key, "email" varchar(255) not null)', $statements[0]); + $this->assertSame('comment on column "users"."email" is \'my first comment\'', $statements[1]); + } + + public function testCreateTemporaryTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->temporary(); + $blueprint->increments('id'); + $blueprint->string('email'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create temporary table "users" ("id" serial not null primary key, "email" varchar(255) not null)', $statements[0]); + } + + public function testDropTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->drop(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop table "users"', $statements[0]); + } + + public function testDropTableIfExists() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropIfExists(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop table if exists "users"', $statements[0]); + } + + public function testDropColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" drop column "foo"', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn(['foo', 'bar']); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" drop column "foo", drop column "bar"', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropColumn('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" drop column "foo", drop column "bar"', $statements[0]); + } + + public function testDropPrimary() + { + $connection = $this->getConnection(); + $connection->getSchemaBuilder()->shouldReceive('parseSchemaAndTable')->andReturn([null, 'users']); + + $blueprint = new Blueprint($connection, 'users'); + $blueprint->dropPrimary(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" drop constraint "users_pkey"', $statements[0]); + } + + public function testDropUnique() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropUnique('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" drop constraint "foo"', $statements[0]); + } + + public function testDropIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropIndex('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop index "foo"', $statements[0]); + } + + public function testDropSpatialIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->dropSpatialIndex(['coordinates']); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop index "geo_coordinates_spatialindex"', $statements[0]); + } + + public function testDropForeign() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropForeign('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" drop constraint "foo"', $statements[0]); + } + + public function testDropTimestamps() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropTimestamps(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" drop column "created_at", drop column "updated_at"', $statements[0]); + } + + public function testDropTimestampsTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropTimestampsTz(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" drop column "created_at", drop column "updated_at"', $statements[0]); + } + + public function testDropMorphs() + { + $blueprint = new Blueprint($this->getConnection(), 'photos'); + $blueprint->dropMorphs('imageable'); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('drop index "photos_imageable_type_imageable_id_index"', $statements[0]); + $this->assertSame('alter table "photos" drop column "imageable_type", drop column "imageable_id"', $statements[1]); + } + + public function testRenameTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rename('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" rename to "foo"', $statements[0]); + } + + public function testRenameIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->renameIndex('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter index "foo" rename to "bar"', $statements[0]); + } + + public function testAddingPrimaryKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->primary('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add primary key ("foo")', $statements[0]); + } + + public function testAddingUniqueKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->unique('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add constraint "bar" unique ("foo")', $statements[0]); + } + + public function testAddingUniqueKeyWithNullsNotDistinct() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->unique('foo', 'bar')->nullsNotDistinct(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add constraint "bar" unique nulls not distinct ("foo")', $statements[0]); + } + + public function testAddingUniqueKeyWithNullsDistinct() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->unique('foo', 'bar')->nullsNotDistinct(false); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add constraint "bar" unique nulls distinct ("foo")', $statements[0]); + } + + public function testAddingUniqueKeyOnline() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->unique('foo')->online(); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('create unique index concurrently "users_foo_unique" on "users" ("foo")', $statements[0]); + $this->assertSame('alter table "users" add constraint "users_foo_unique" unique using index "users_foo_unique"', $statements[1]); + } + + public function testAddingIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index(['foo', 'bar'], 'baz'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "baz" on "users" ("foo", "bar")', $statements[0]); + } + + public function testAddingIndexWithAlgorithm() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index(['foo', 'bar'], 'baz', 'hash'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "baz" on "users" using hash ("foo", "bar")', $statements[0]); + } + + public function testAddingIndexOnline() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index('foo', 'baz')->online(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index concurrently "baz" on "users" ("foo")', $statements[0]); + } + + public function testAddingFulltextIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->fulltext('body'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'english\', "body")))', $statements[0]); + } + + public function testAddingFulltextIndexMultipleColumns() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->fulltext(['body', 'title']); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "users_body_title_fulltext" on "users" using gin ((to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")))', $statements[0]); + } + + public function testAddingFulltextIndexWithLanguage() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->fulltext('body')->language('spanish'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'spanish\', "body")))', $statements[0]); + } + + public function testAddingFulltextIndexOnline() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->fulltext('body')->online(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index concurrently "users_body_fulltext" on "users" using gin ((to_tsvector(\'english\', "body")))', $statements[0]); + } + + public function testAddingFulltextIndexWithFluency() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('body')->fulltext(); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('create index "users_body_fulltext" on "users" using gin ((to_tsvector(\'english\', "body")))', $statements[1]); + } + + public function testAddingSpatialIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->spatialIndex('coordinates'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "geo_coordinates_spatialindex" on "geo" using gist ("coordinates")', $statements[0]); + } + + public function testAddingSpatialIndexOnline() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->spatialIndex('coordinates')->online(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index concurrently "geo_coordinates_spatialindex" on "geo" using gist ("coordinates")', $statements[0]); + } + + public function testAddingFluentSpatialIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point')->spatialIndex(); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('create index "geo_coordinates_spatialindex" on "geo" using gist ("coordinates")', $statements[1]); + } + + public function testAddingSpatialIndexWithOperatorClass() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->spatialIndex('coordinates', 'my_index', 'point_ops'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "my_index" on "geo" using gist ("coordinates" point_ops)', $statements[0]); + } + + public function testAddingSpatialIndexWithOperatorClassMultipleColumns() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->spatialIndex(['coordinates', 'location'], 'my_index', 'point_ops'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "my_index" on "geo" using gist ("coordinates" point_ops, "location" point_ops)', $statements[0]); + } + + public function testAddingSpatialIndexWithOperatorClassOnline() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->spatialIndex('coordinates', 'my_index', 'point_ops')->online(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index concurrently "my_index" on "geo" using gist ("coordinates" point_ops)', $statements[0]); + } + + public function testAddingVectorIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'posts'); + $blueprint->vectorIndex('embeddings'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "posts_embeddings_vectorindex" on "posts" using hnsw ("embeddings" vector_cosine_ops)', $statements[0]); + } + + public function testAddingVectorIndexOnline() + { + $blueprint = new Blueprint($this->getConnection(), 'posts'); + $blueprint->vectorIndex('embeddings')->online(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index concurrently "posts_embeddings_vectorindex" on "posts" using hnsw ("embeddings" vector_cosine_ops)', $statements[0]); + } + + public function testAddingVectorIndexWithName() + { + $blueprint = new Blueprint($this->getConnection(), 'posts'); + $blueprint->vectorIndex('embeddings', 'my_vector_index'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "my_vector_index" on "posts" using hnsw ("embeddings" vector_cosine_ops)', $statements[0]); + } + + public function testAddingFluentVectorIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'posts'); + $blueprint->vector('embeddings', 1536)->vectorIndex(); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('create index "posts_embeddings_vectorindex" on "posts" using hnsw ("embeddings" vector_cosine_ops)', $statements[1]); + } + + public function testAddingFluentIndexOnVectorColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'posts'); + $blueprint->vector('embeddings', 1536)->index(); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('create index "posts_embeddings_vectorindex" on "posts" using hnsw ("embeddings" vector_cosine_ops)', $statements[1]); + } + + public function testAddingRawIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rawIndex('(function(column))', 'raw_index'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "raw_index" on "users" ((function(column)))', $statements[0]); + } + + public function testAddingRawIndexOnline() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rawIndex('(function(column))', 'raw_index')->online(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index concurrently "raw_index" on "users" ((function(column)))', $statements[0]); + } + + public function testAddingIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->increments('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" serial not null primary key', $statements[0]); + } + + public function testAddingSmallIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" smallserial not null primary key', $statements[0]); + } + + public function testAddingMediumIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" serial not null primary key', $statements[0]); + } + + public function testAddingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" bigserial not null primary key', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" bigserial not null primary key', $statements[0]); + } + + public function testAddingForeignID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $foreignId = $blueprint->foreignId('foo'); + $blueprint->foreignId('company_id')->constrained(); + $blueprint->foreignId('laravel_idea_id')->constrained(); + $blueprint->foreignId('team_id')->references('id')->on('teams'); + $blueprint->foreignId('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql(); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignId); + $this->assertSame([ + 'alter table "users" add column "foo" bigint not null', + 'alter table "users" add column "company_id" bigint not null', + 'alter table "users" add constraint "users_company_id_foreign" foreign key ("company_id") references "companies" ("id")', + 'alter table "users" add column "laravel_idea_id" bigint not null', + 'alter table "users" add constraint "users_laravel_idea_id_foreign" foreign key ("laravel_idea_id") references "laravel_ideas" ("id")', + 'alter table "users" add column "team_id" bigint not null', + 'alter table "users" add constraint "users_team_id_foreign" foreign key ("team_id") references "teams" ("id")', + 'alter table "users" add column "team_column_id" bigint not null', + 'alter table "users" add constraint "users_team_column_id_foreign" foreign key ("team_column_id") references "teams" ("id")', + ], $statements); + } + + public function testAddingForeignIdSpecifyingIndexNameInConstraint() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreignId('company_id')->constrained(indexName: 'my_index'); + $statements = $blueprint->toSql(); + $this->assertSame([ + 'alter table "users" add column "company_id" bigint not null', + 'alter table "users" add constraint "my_index" foreign key ("company_id") references "companies" ("id")', + ], $statements); + } + + public function testAddingBigIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" bigserial not null primary key', $statements[0]); + } + + public function testAddingString() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar(255) not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar(100) not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100)->nullable()->default('bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar(100) null default \'bar\'', $statements[0]); + } + + public function testAddingStringWithoutLengthLimit() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar(255) not null', $statements[0]); + + Builder::$defaultStringLength = null; + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo'); + $statements = $blueprint->toSql(); + + try { + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + } finally { + Builder::$defaultStringLength = 255; + } + } + + public function testAddingCharWithoutLengthLimit() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->char('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" char(255) not null', $statements[0]); + + Builder::$defaultStringLength = null; + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->char('foo'); + $statements = $blueprint->toSql(); + + try { + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" char not null', $statements[0]); + } finally { + Builder::$defaultStringLength = 255; + } + } + + public function testAddingText() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->text('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" text not null', $statements[0]); + } + + public function testAddingBigInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" bigint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" bigserial not null primary key', $statements[0]); + } + + public function testAddingInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" serial not null primary key', $statements[0]); + } + + public function testAddingMediumInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" serial not null primary key', $statements[0]); + } + + public function testAddingTinyInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->tinyInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" smallint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->tinyInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" smallserial not null primary key', $statements[0]); + } + + public function testAddingSmallInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" smallint not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" smallserial not null primary key', $statements[0]); + } + + public function testAddingFloat() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->float('foo', 5); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" float(5) not null', $statements[0]); + } + + public function testAddingDouble() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->double('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" double precision not null', $statements[0]); + } + + public function testAddingDecimal() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->decimal('foo', 5, 2); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" decimal(5, 2) not null', $statements[0]); + } + + public function testAddingBoolean() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->boolean('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" boolean not null', $statements[0]); + } + + public function testAddingEnum() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->enum('role', ['member', 'admin']); + $blueprint->enum('status', Foo::cases()); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table "users" add column "role" varchar(255) check ("role" in (\'member\', \'admin\')) not null', $statements[0]); + $this->assertSame('alter table "users" add column "status" varchar(255) check ("status" in (\'bar\')) not null', $statements[1]); + } + + public function testAddingDate() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->date('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" date not null', $statements[0]); + } + + public function testAddingDateWithDefaultCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->date('foo')->useCurrent(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" date not null default CURRENT_DATE', $statements[0]); + } + + public function testAddingYear() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->year('birth_year'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "birth_year" integer not null', $statements[0]); + } + + public function testAddingYearWithDefaultCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->year('birth_year')->useCurrent(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "birth_year" integer not null default EXTRACT(YEAR FROM CURRENT_DATE)', $statements[0]); + } + + public function testAddingJson() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->json('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" json not null', $statements[0]); + } + + public function testAddingJsonb() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->jsonb('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" jsonb not null', $statements[0]); + } + + #[DataProvider('datetimeAndPrecisionProvider')] + public function testAddingDatetimeMethods(string $method, string $type, ?int $userPrecision, false|int|null $grammarPrecision, ?int $expected) + { + PostgresBuilder::defaultTimePrecision($grammarPrecision); + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->{$method}('created_at', $userPrecision); + $statements = $blueprint->toSql(); + $type = is_null($expected) ? $type : "{$type}({$expected})"; + $with = str_contains($method, 'Tz') ? 'with' : 'without'; + $this->assertCount(1, $statements); + $this->assertSame("alter table \"users\" add column \"created_at\" {$type} {$with} time zone not null", $statements[0]); + } + + #[TestWith(['timestamps'])] + #[TestWith(['timestampsTz'])] + public function testAddingTimestamps(string $method) + { + PostgresBuilder::defaultTimePrecision(0); + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->{$method}(); + $statements = $blueprint->toSql(); + $with = str_contains($method, 'Tz') ? 'with' : 'without'; + $this->assertCount(2, $statements); + $this->assertSame([ + "alter table \"users\" add column \"created_at\" timestamp(0) {$with} time zone null", + "alter table \"users\" add column \"updated_at\" timestamp(0) {$with} time zone null", + ], $statements); + } + + public function testAddingBinary() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->binary('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" bytea not null', $statements[0]); + } + + public function testAddingUuid() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->uuid('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" uuid not null', $statements[0]); + } + + public function testAddingUuidDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->uuid(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "uuid" uuid not null', $statements[0]); + } + + public function testAddingForeignUuid() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $foreignUuid = $blueprint->foreignUuid('foo'); + $blueprint->foreignUuid('company_id')->constrained(); + $blueprint->foreignUuid('laravel_idea_id')->constrained(); + $blueprint->foreignUuid('team_id')->references('id')->on('teams'); + $blueprint->foreignUuid('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql(); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignUuid); + $this->assertSame([ + 'alter table "users" add column "foo" uuid not null', + 'alter table "users" add column "company_id" uuid not null', + 'alter table "users" add constraint "users_company_id_foreign" foreign key ("company_id") references "companies" ("id")', + 'alter table "users" add column "laravel_idea_id" uuid not null', + 'alter table "users" add constraint "users_laravel_idea_id_foreign" foreign key ("laravel_idea_id") references "laravel_ideas" ("id")', + 'alter table "users" add column "team_id" uuid not null', + 'alter table "users" add constraint "users_team_id_foreign" foreign key ("team_id") references "teams" ("id")', + 'alter table "users" add column "team_column_id" uuid not null', + 'alter table "users" add constraint "users_team_column_id_foreign" foreign key ("team_column_id") references "teams" ("id")', + ], $statements); + } + + public function testAddingGeneratedAs() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->increments('foo')->generatedAs(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null generated by default as identity primary key', $statements[0]); + // With always modifier + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->increments('foo')->generatedAs()->always(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null generated always as identity primary key', $statements[0]); + // With sequence options + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->increments('foo')->generatedAs('increment by 10 start with 100'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null generated by default as identity (increment by 10 start with 100) primary key', $statements[0]); + // Not a primary key + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo')->generatedAs(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null generated by default as identity', $statements[0]); + } + + public function testAddingVirtualAs() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo')->nullable(); + $blueprint->boolean('bar')->virtualAs('foo is not null'); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table "users" add column "foo" integer null', + 'alter table "users" add column "bar" boolean not null generated always as (foo is not null) virtual', + ], $statements); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo')->nullable(); + $blueprint->boolean('bar')->virtualAs(new Expression('foo is not null')); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table "users" add column "foo" integer null', + 'alter table "users" add column "bar" boolean not null generated always as (foo is not null) virtual', + ], $statements); + } + + public function testAddingStoredAs() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo')->nullable(); + $blueprint->boolean('bar')->storedAs('foo is not null'); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table "users" add column "foo" integer null', + 'alter table "users" add column "bar" boolean not null generated always as (foo is not null) stored', + ], $statements); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo')->nullable(); + $blueprint->boolean('bar')->storedAs(new Expression('foo is not null')); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertSame([ + 'alter table "users" add column "foo" integer null', + 'alter table "users" add column "bar" boolean not null generated always as (foo is not null) stored', + ], $statements); + } + + public function testAddingIpAddress() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->ipAddress('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" inet not null', $statements[0]); + } + + public function testAddingIpAddressDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->ipAddress(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "ip_address" inet not null', $statements[0]); + } + + public function testAddingMacAddress() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->macAddress('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" macaddr not null', $statements[0]); + } + + public function testAddingMacAddressDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->macAddress(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "mac_address" macaddr not null', $statements[0]); + } + + public function testCompileForeign() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('parent_id')->references('id')->on('parents')->onDelete('cascade')->deferrable(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add constraint "users_parent_id_foreign" foreign key ("parent_id") references "parents" ("id") on delete cascade deferrable', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('parent_id')->references('id')->on('parents')->onDelete('cascade')->deferrable(false)->initiallyImmediate(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add constraint "users_parent_id_foreign" foreign key ("parent_id") references "parents" ("id") on delete cascade not deferrable', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('parent_id')->references('id')->on('parents')->onDelete('cascade')->deferrable()->initiallyImmediate(false); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add constraint "users_parent_id_foreign" foreign key ("parent_id") references "parents" ("id") on delete cascade deferrable initially deferred', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->foreign('parent_id')->references('id')->on('parents')->onDelete('cascade')->deferrable()->notValid(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add constraint "users_parent_id_foreign" foreign key ("parent_id") references "parents" ("id") on delete cascade deferrable not valid', $statements[0]); + } + + public function testAddingGeometry() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry not null', $statements[0]); + } + + public function testAddingGeography() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geography('coordinates', 'pointzm', 4269); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geography(pointzm,4269) not null', $statements[0]); + } + + public function testAddingPoint() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry(point) not null', $statements[0]); + } + + public function testAddingPointWithSrid() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'point', 4269); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry(point,4269) not null', $statements[0]); + } + + public function testAddingLineString() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'linestring'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry(linestring) not null', $statements[0]); + } + + public function testAddingPolygon() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'polygon'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry(polygon) not null', $statements[0]); + } + + public function testAddingGeometryCollection() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'geometrycollection'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry(geometrycollection) not null', $statements[0]); + } + + public function testAddingMultiPoint() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'multipoint'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry(multipoint) not null', $statements[0]); + } + + public function testAddingMultiLineString() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'multilinestring'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry(multilinestring) not null', $statements[0]); + } + + public function testAddingMultiPolygon() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates', 'multipolygon'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry(multipolygon) not null', $statements[0]); + } + + public function testCreateDatabase() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8_foo'); + $statement = $this->getGrammar($connection)->compileCreateDatabase('my_database_a'); + + $this->assertSame( + 'create database "my_database_a" encoding "utf8_foo"', + $statement + ); + + $connection = $this->getConnection(); + $connection->shouldReceive('getConfig')->once()->once()->with('charset')->andReturn('utf8_bar'); + $statement = $this->getGrammar($connection)->compileCreateDatabase('my_database_b'); + + $this->assertSame( + 'create database "my_database_b" encoding "utf8_bar"', + $statement + ); + } + + public function testDropDatabaseIfExists() + { + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_a'); + + $this->assertSame( + 'drop database if exists "my_database_a"', + $statement + ); + + $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_b'); + + $this->assertSame( + 'drop database if exists "my_database_b"', + $statement + ); + } + + public function testDropAllTablesEscapesTableNames() + { + $statement = $this->getGrammar()->compileDropAllTables(['alpha', 'beta', 'gamma']); + + $this->assertSame('drop table "alpha", "beta", "gamma" cascade', $statement); + } + + public function testDropAllViewsEscapesTableNames() + { + $statement = $this->getGrammar()->compileDropAllViews(['alpha', 'beta', 'gamma']); + + $this->assertSame('drop view "alpha", "beta", "gamma" cascade', $statement); + } + + public function testDropAllTypesEscapesTableNames() + { + $statement = $this->getGrammar()->compileDropAllTypes(['alpha', 'beta', 'gamma']); + + $this->assertSame('drop type "alpha", "beta", "gamma" cascade', $statement); + } + + public function testDropAllTablesWithPrefixAndSchema() + { + $connection = $this->getConnection(prefix: 'prefix_'); + $statement = $this->getGrammar($connection)->compileDropAllTables(['schema.alpha', 'schema.beta', 'schema.gamma']); + + $this->assertSame('drop table "schema"."alpha", "schema"."beta", "schema"."gamma" cascade', $statement); + } + + public function testDropAllViewsWithPrefixAndSchema() + { + $connection = $this->getConnection(prefix: 'prefix_'); + $statement = $this->getGrammar($connection)->compileDropAllViews(['schema.alpha', 'schema.beta', 'schema.gamma']); + + $this->assertSame('drop view "schema"."alpha", "schema"."beta", "schema"."gamma" cascade', $statement); + } + + public function testDropAllTypesWithPrefixAndSchema() + { + $connection = $this->getConnection(prefix: 'prefix_'); + $statement = $this->getGrammar($connection)->compileDropAllTypes(['schema.alpha', 'schema.beta', 'schema.gamma']); + + $this->assertSame('drop type "schema"."alpha", "schema"."beta", "schema"."gamma" cascade', $statement); + } + + public function testDropAllDomainsWithPrefixAndSchema() + { + $connection = $this->getConnection(prefix: 'prefix_'); + $statement = $this->getGrammar($connection)->compileDropAllDomains(['schema.alpha', 'schema.beta', 'schema.gamma']); + + $this->assertSame('drop domain "schema"."alpha", "schema"."beta", "schema"."gamma" cascade', $statement); + } + + public function testCompileColumns() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getServerVersion')->once()->andReturn('12.0.0'); + + $statement = $connection->getSchemaGrammar()->compileColumns('public', 'table'); + + $this->assertStringContainsString("where c.relname = 'table' and n.nspname = 'public'", $statement); + } + + protected function getConnection( + ?PostgresGrammar $grammar = null, + ?PostgresBuilder $builder = null, + string $prefix = '' + ) { + $connection = m::mock(Connection::class) + ->shouldReceive('getTablePrefix')->andReturn($prefix) + ->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(null) + ->getMock(); + + $grammar ??= $this->getGrammar($connection); + $builder ??= $this->getBuilder(); + + return $connection + ->shouldReceive('getSchemaGrammar')->andReturn($grammar) + ->shouldReceive('getSchemaBuilder')->andReturn($builder) + ->getMock(); + } + + public function getGrammar(?Connection $connection = null) + { + return new PostgresGrammar($connection ?? $this->getConnection()); + } + + public function getBuilder() + { + return mock(PostgresBuilder::class); + } + + /** @return list */ + public static function datetimeAndPrecisionProvider(): array + { + $methods = [ + ['method' => 'datetime', 'type' => 'timestamp'], + ['method' => 'datetimeTz', 'type' => 'timestamp'], + ['method' => 'timestamp', 'type' => 'timestamp'], + ['method' => 'timestampTz', 'type' => 'timestamp'], + ['method' => 'time', 'type' => 'time'], + ['method' => 'timeTz', 'type' => 'time'], + ]; + $precisions = [ + 'user can override grammar default' => ['userPrecision' => 1, 'grammarPrecision' => null, 'expected' => 1], + 'fallback to grammar default' => ['userPrecision' => null, 'grammarPrecision' => 5, 'expected' => 5], + 'fallback to database default' => ['userPrecision' => null, 'grammarPrecision' => null, 'expected' => null], + ]; + + $result = []; + + foreach ($methods as $datetime) { + foreach ($precisions as $precision) { + $result[] = array_merge($datetime, $precision); + } + } + + return $result; + } + + public function testGrammarsAreMacroable() + { + // compileReplace macro. + $this->getGrammar()::macro('compileReplace', function () { + return true; + }); + + $c = $this->getGrammar()::compileReplace(); + + $this->assertTrue($c); + } +} diff --git a/tests/Database/Laravel/DatabaseProcessorTest.php b/tests/Database/Laravel/DatabaseProcessorTest.php new file mode 100755 index 000000000..b74976778 --- /dev/null +++ b/tests/Database/Laravel/DatabaseProcessorTest.php @@ -0,0 +1,40 @@ +createMock(ProcessorTestPDOStub::class); + $pdo->expects($this->once())->method('lastInsertId')->with($this->equalTo('id'))->willReturn('1'); + $connection = m::mock(Connection::class); + $connection->shouldReceive('insert')->once()->with('sql', ['foo']); + $connection->shouldReceive('getPdo')->once()->andReturn($pdo); + $builder = m::mock(Builder::class); + $builder->shouldReceive('getConnection')->andReturn($connection); + $processor = new Processor; + $result = $processor->processInsertGetId($builder, 'sql', ['foo'], 'id'); + $this->assertSame(1, $result); + } +} + +class ProcessorTestPDOStub extends PDO +{ + public function __construct() + { + // + } + + public function lastInsertId($sequence = null): string|false + { + return ''; + } +} diff --git a/tests/Database/Laravel/DatabaseQueryBuilderTest.php b/tests/Database/Laravel/DatabaseQueryBuilderTest.php new file mode 100755 index 000000000..48f1a0fb0 --- /dev/null +++ b/tests/Database/Laravel/DatabaseQueryBuilderTest.php @@ -0,0 +1,7222 @@ +getBuilder(); + $builder->select('*')->from('users'); + $this->assertSame('select * from "users"', $builder->toSql()); + } + + public function testBasicSelectWithGetColumns() + { + $builder = $this->getBuilder(); + $builder->getProcessor()->shouldReceive('processSelect'); + $builder->getConnection()->shouldReceive('select')->once()->andReturnUsing(function ($sql) { + $this->assertSame('select * from "users"', $sql); + }); + $builder->getConnection()->shouldReceive('select')->once()->andReturnUsing(function ($sql) { + $this->assertSame('select "foo", "bar" from "users"', $sql); + }); + $builder->getConnection()->shouldReceive('select')->once()->andReturnUsing(function ($sql) { + $this->assertSame('select "baz" from "users"', $sql); + }); + + $builder->from('users')->get(); + $this->assertNull($builder->columns); + + $builder->from('users')->get(['foo', 'bar']); + $this->assertNull($builder->columns); + + $builder->from('users')->get('baz'); + $this->assertNull($builder->columns); + + $this->assertSame('select * from "users"', $builder->toSql()); + $this->assertNull($builder->columns); + } + + public function testBasicSelectUseWritePdo() + { + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->getConnection()->shouldReceive('select')->once() + ->with('select * from `users`', [], false); + $builder->useWritePdo()->select('*')->from('users')->get(); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->getConnection()->shouldReceive('select')->once() + ->with('select * from `users`', [], true); + $builder->select('*')->from('users')->get(); + } + + public function testBasicTableWrappingProtectsQuotationMarks() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('some"table'); + $this->assertSame('select * from "some""table"', $builder->toSql()); + } + + public function testAliasWrappingAsWholeConstant() + { + $builder = $this->getBuilder(); + $builder->select('x.y as foo.bar')->from('baz'); + $this->assertSame('select "x"."y" as "foo.bar" from "baz"', $builder->toSql()); + } + + public function testAliasWrappingWithSpacesInDatabaseName() + { + $builder = $this->getBuilder(); + $builder->select('w x.y.z as foo.bar')->from('baz'); + $this->assertSame('select "w x"."y"."z" as "foo.bar" from "baz"', $builder->toSql()); + } + + public function testAddingSelects() + { + $builder = $this->getBuilder(); + $builder->select('foo')->addSelect('bar')->addSelect(['baz', 'boom'])->addSelect('bar')->from('users'); + $this->assertSame('select "foo", "bar", "baz", "boom" from "users"', $builder->toSql()); + } + + public function testBasicSelectWithPrefix() + { + $builder = $this->getBuilder(prefix: 'prefix_'); + $builder->select('*')->from('users'); + $this->assertSame('select * from "prefix_users"', $builder->toSql()); + } + + public function testBasicSelectDistinct() + { + $builder = $this->getBuilder(); + $builder->distinct()->select('foo', 'bar')->from('users'); + $this->assertSame('select distinct "foo", "bar" from "users"', $builder->toSql()); + } + + public function testBasicSelectDistinctOnColumns() + { + $builder = $this->getBuilder(); + $builder->distinct('foo')->select('foo', 'bar')->from('users'); + $this->assertSame('select distinct "foo", "bar" from "users"', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->distinct('foo')->select('foo', 'bar')->from('users'); + $this->assertSame('select distinct on ("foo") "foo", "bar" from "users"', $builder->toSql()); + } + + public function testBasicAlias() + { + $builder = $this->getBuilder(); + $builder->select('foo as bar')->from('users'); + $this->assertSame('select "foo" as "bar" from "users"', $builder->toSql()); + } + + public function testAliasWithPrefix() + { + $builder = $this->getBuilder(prefix: 'prefix_'); + $builder->select('*')->from('users as people'); + $this->assertSame('select * from "prefix_users" as "prefix_people"', $builder->toSql()); + } + + public function testJoinAliasesWithPrefix() + { + $builder = $this->getBuilder(prefix: 'prefix_'); + $builder->select('*')->from('services')->join('translations AS t', 't.item_id', '=', 'services.id'); + $this->assertSame('select * from "prefix_services" inner join "prefix_translations" as "prefix_t" on "prefix_t"."item_id" = "prefix_services"."id"', $builder->toSql()); + } + + public function testBasicTableWrapping() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('public.users'); + $this->assertSame('select * from "public"."users"', $builder->toSql()); + } + + public function testWhenCallback() + { + $callback = function ($query, $condition) { + $this->assertTrue($condition); + + $query->where('id', '=', 1); + }; + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->when(true, $callback)->where('email', 'foo'); + $this->assertSame('select * from "users" where "id" = ? and "email" = ?', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->when(false, $callback)->where('email', 'foo'); + $this->assertSame('select * from "users" where "email" = ?', $builder->toSql()); + } + + public function testWhenCallbackWithReturn() + { + $callback = function ($query, $condition) { + $this->assertTrue($condition); + + return $query->where('id', '=', 1); + }; + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->when(true, $callback)->where('email', 'foo'); + $this->assertSame('select * from "users" where "id" = ? and "email" = ?', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->when(false, $callback)->where('email', 'foo'); + $this->assertSame('select * from "users" where "email" = ?', $builder->toSql()); + } + + public function testWhenCallbackWithDefault() + { + $callback = function ($query, $condition) { + $this->assertSame('truthy', $condition); + + $query->where('id', '=', 1); + }; + + $default = function ($query, $condition) { + $this->assertEquals(0, $condition); + + $query->where('id', '=', 2); + }; + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->when('truthy', $callback, $default)->where('email', 'foo'); + $this->assertSame('select * from "users" where "id" = ? and "email" = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 'foo'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->when(0, $callback, $default)->where('email', 'foo'); + $this->assertSame('select * from "users" where "id" = ? and "email" = ?', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => 'foo'], $builder->getBindings()); + } + + public function testUnlessCallback() + { + $callback = function ($query, $condition) { + $this->assertFalse($condition); + + $query->where('id', '=', 1); + }; + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->unless(false, $callback)->where('email', 'foo'); + $this->assertSame('select * from "users" where "id" = ? and "email" = ?', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->unless(true, $callback)->where('email', 'foo'); + $this->assertSame('select * from "users" where "email" = ?', $builder->toSql()); + } + + public function testUnlessCallbackWithReturn() + { + $callback = function ($query, $condition) { + $this->assertFalse($condition); + + return $query->where('id', '=', 1); + }; + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->unless(false, $callback)->where('email', 'foo'); + $this->assertSame('select * from "users" where "id" = ? and "email" = ?', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->unless(true, $callback)->where('email', 'foo'); + $this->assertSame('select * from "users" where "email" = ?', $builder->toSql()); + } + + public function testUnlessCallbackWithDefault() + { + $callback = function ($query, $condition) { + $this->assertEquals(0, $condition); + + $query->where('id', '=', 1); + }; + + $default = function ($query, $condition) { + $this->assertSame('truthy', $condition); + + $query->where('id', '=', 2); + }; + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->unless(0, $callback, $default)->where('email', 'foo'); + $this->assertSame('select * from "users" where "id" = ? and "email" = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 'foo'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->unless('truthy', $callback, $default)->where('email', 'foo'); + $this->assertSame('select * from "users" where "id" = ? and "email" = ?', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => 'foo'], $builder->getBindings()); + } + + public function testTapCallback() + { + $callback = function ($query) { + return $query->where('id', '=', 1); + }; + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->tap($callback)->where('email', 'foo'); + $this->assertSame('select * from "users" where "id" = ? and "email" = ?', $builder->toSql()); + } + + public function testPipeCallback() + { + $query = $this->getBuilder(); + + $result = $query->pipe(fn (Builder $query) => 5); + $this->assertSame(5, $result); + + $result = $query->pipe(fn (Builder $query) => null); + $this->assertSame($query, $result); + + $result = $query->pipe(function (Builder $query) { + // + }); + $this->assertSame($query, $result); + + $this->assertCount(0, $query->wheres); + $result = $query->pipe(fn (Builder $query) => $query->where('foo', 'bar')); + $this->assertSame($query, $result); + $this->assertCount(1, $query->wheres); + } + + public function testBasicWheres() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1); + $this->assertSame('select * from "users" where "id" = ?', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testBasicWhereNot() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot('name', 'foo')->whereNot('name', '<>', 'bar'); + $this->assertSame('select * from "users" where not "name" = ? and not "name" <> ?', $builder->toSql()); + $this->assertEquals(['foo', 'bar'], $builder->getBindings()); + } + + public function testWheresWithArrayValue() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', [12]); + $this->assertSame('select * from "users" where "id" = ?', $builder->toSql()); + $this->assertEquals([0 => 12], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', [12, 30]); + $this->assertSame('select * from "users" where "id" = ?', $builder->toSql()); + $this->assertEquals([0 => 12], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '!=', [12, 30]); + $this->assertSame('select * from "users" where "id" != ?', $builder->toSql()); + $this->assertEquals([0 => 12], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '<>', [12, 30]); + $this->assertSame('select * from "users" where "id" <> ?', $builder->toSql()); + $this->assertEquals([0 => 12], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', [[12, 30]]); + $this->assertSame('select * from "users" where "id" = ?', $builder->toSql()); + $this->assertEquals([0 => 12], $builder->getBindings()); + } + + public function testMySqlWrappingProtectsQuotationMarks() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->From('some`table'); + $this->assertSame('select * from `some``table`', $builder->toSql()); + } + + public function testDateBasedWheresAcceptsTwoArguments() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', 1); + $this->assertSame('select * from `users` where date(`created_at`) = ?', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereDay('created_at', 1); + $this->assertSame('select * from `users` where day(`created_at`) = ?', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereMonth('created_at', 1); + $this->assertSame('select * from `users` where month(`created_at`) = ?', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereYear('created_at', 1); + $this->assertSame('select * from `users` where year(`created_at`) = ?', $builder->toSql()); + } + + public function testDateBasedOrWheresAcceptsTwoArguments() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', 1)->orWhereDate('created_at', 1); + $this->assertSame('select * from `users` where `id` = ? or date(`created_at`) = ?', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', 1)->orWhereDay('created_at', 1); + $this->assertSame('select * from `users` where `id` = ? or day(`created_at`) = ?', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', 1)->orWhereMonth('created_at', 1); + $this->assertSame('select * from `users` where `id` = ? or month(`created_at`) = ?', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', 1)->orWhereYear('created_at', 1); + $this->assertSame('select * from `users` where `id` = ? or year(`created_at`) = ?', $builder->toSql()); + } + + public function testDateBasedWheresExpressionIsNotBound() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', new Raw('NOW()'))->where('admin', true); + $this->assertEquals([true], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereDay('created_at', new Raw('NOW()')); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereMonth('created_at', new Raw('NOW()')); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereYear('created_at', new Raw('NOW()')); + $this->assertEquals([], $builder->getBindings()); + } + + public function testWhereDateMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', '=', '2015-12-21'); + $this->assertSame('select * from `users` where date(`created_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => '2015-12-21'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', '=', new Raw('NOW()')); + $this->assertSame('select * from `users` where date(`created_at`) = NOW()', $builder->toSql()); + } + + public function testWhereDayMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereDay('created_at', '=', 1); + $this->assertSame('select * from `users` where day(`created_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testOrWhereDayMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereDay('created_at', '=', 1)->orWhereDay('created_at', '=', 2); + $this->assertSame('select * from `users` where day(`created_at`) = ? or day(`created_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + } + + public function testOrWhereDayPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereDay('created_at', '=', 1)->orWhereDay('created_at', '=', 2); + $this->assertSame('select * from "users" where extract(day from "created_at") = ? or extract(day from "created_at") = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + } + + public function testOrWhereDaySqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereDay('created_at', '=', 1)->orWhereDay('created_at', '=', 2); + $this->assertSame('select * from [users] where day([created_at]) = ? or day([created_at]) = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + } + + public function testWhereMonthMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereMonth('created_at', '=', 5); + $this->assertSame('select * from `users` where month(`created_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => 5], $builder->getBindings()); + } + + public function testOrWhereMonthMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereMonth('created_at', '=', 5)->orWhereMonth('created_at', '=', 6); + $this->assertSame('select * from `users` where month(`created_at`) = ? or month(`created_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => 5, 1 => 6], $builder->getBindings()); + } + + public function testOrWhereMonthPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereMonth('created_at', '=', 5)->orWhereMonth('created_at', '=', 6); + $this->assertSame('select * from "users" where extract(month from "created_at") = ? or extract(month from "created_at") = ?', $builder->toSql()); + $this->assertEquals([0 => 5, 1 => 6], $builder->getBindings()); + } + + public function testOrWhereMonthSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereMonth('created_at', '=', 5)->orWhereMonth('created_at', '=', 6); + $this->assertSame('select * from [users] where month([created_at]) = ? or month([created_at]) = ?', $builder->toSql()); + $this->assertEquals([0 => 5, 1 => 6], $builder->getBindings()); + } + + public function testWhereYearMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereYear('created_at', '=', 2014); + $this->assertSame('select * from `users` where year(`created_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => 2014], $builder->getBindings()); + } + + public function testOrWhereYearMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereYear('created_at', '=', 2014)->orWhereYear('created_at', '=', 2015); + $this->assertSame('select * from `users` where year(`created_at`) = ? or year(`created_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => 2014, 1 => 2015], $builder->getBindings()); + } + + public function testOrWhereYearPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereYear('created_at', '=', 2014)->orWhereYear('created_at', '=', 2015); + $this->assertSame('select * from "users" where extract(year from "created_at") = ? or extract(year from "created_at") = ?', $builder->toSql()); + $this->assertEquals([0 => 2014, 1 => 2015], $builder->getBindings()); + } + + public function testOrWhereYearSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereYear('created_at', '=', 2014)->orWhereYear('created_at', '=', 2015); + $this->assertSame('select * from [users] where year([created_at]) = ? or year([created_at]) = ?', $builder->toSql()); + $this->assertEquals([0 => 2014, 1 => 2015], $builder->getBindings()); + } + + public function testWhereTimeMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '>=', '22:00'); + $this->assertSame('select * from `users` where time(`created_at`) >= ?', $builder->toSql()); + $this->assertEquals([0 => '22:00'], $builder->getBindings()); + } + + public function testWhereTimeOperatorOptionalMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '22:00'); + $this->assertSame('select * from `users` where time(`created_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => '22:00'], $builder->getBindings()); + } + + public function testWhereTimeOperatorOptionalPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '22:00'); + $this->assertSame('select * from "users" where "created_at"::time = ?', $builder->toSql()); + $this->assertEquals([0 => '22:00'], $builder->getBindings()); + } + + public function testWhereTimeSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '22:00'); + $this->assertSame('select * from [users] where cast([created_at] as time) = ?', $builder->toSql()); + $this->assertEquals([0 => '22:00'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', new Raw('NOW()')); + $this->assertSame('select * from [users] where cast([created_at] as time) = NOW()', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + + public function testOrWhereTimeMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '<=', '10:00')->orWhereTime('created_at', '>=', '22:00'); + $this->assertSame('select * from `users` where time(`created_at`) <= ? or time(`created_at`) >= ?', $builder->toSql()); + $this->assertEquals([0 => '10:00', 1 => '22:00'], $builder->getBindings()); + } + + public function testOrWhereTimePostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '<=', '10:00')->orWhereTime('created_at', '>=', '22:00'); + $this->assertSame('select * from "users" where "created_at"::time <= ? or "created_at"::time >= ?', $builder->toSql()); + $this->assertEquals([0 => '10:00', 1 => '22:00'], $builder->getBindings()); + } + + public function testOrWhereTimeSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '<=', '10:00')->orWhereTime('created_at', '>=', '22:00'); + $this->assertSame('select * from [users] where cast([created_at] as time) <= ? or cast([created_at] as time) >= ?', $builder->toSql()); + $this->assertEquals([0 => '10:00', 1 => '22:00'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '<=', '10:00')->orWhereTime('created_at', new Raw('NOW()')); + $this->assertSame('select * from [users] where cast([created_at] as time) <= ? or cast([created_at] as time) = NOW()', $builder->toSql()); + $this->assertEquals([0 => '10:00'], $builder->getBindings()); + } + + public function testWhereDatePostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', '=', '2015-12-21'); + $this->assertSame('select * from "users" where "created_at"::date = ?', $builder->toSql()); + $this->assertEquals([0 => '2015-12-21'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', new Raw('NOW()')); + $this->assertSame('select * from "users" where "created_at"::date = NOW()', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereDate('result->created_at', new Raw('NOW()')); + $this->assertSame('select * from "users" where ("result"->>\'created_at\')::date = NOW()', $builder->toSql()); + } + + public function testWhereDayPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereDay('created_at', '=', 1); + $this->assertSame('select * from "users" where extract(day from "created_at") = ?', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testWhereMonthPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereMonth('created_at', '=', 5); + $this->assertSame('select * from "users" where extract(month from "created_at") = ?', $builder->toSql()); + $this->assertEquals([0 => 5], $builder->getBindings()); + } + + public function testWhereYearPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereYear('created_at', '=', 2014); + $this->assertSame('select * from "users" where extract(year from "created_at") = ?', $builder->toSql()); + $this->assertEquals([0 => 2014], $builder->getBindings()); + } + + public function testWhereTimePostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '>=', '22:00'); + $this->assertSame('select * from "users" where "created_at"::time >= ?', $builder->toSql()); + $this->assertEquals([0 => '22:00'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereTime('result->created_at', '>=', '22:00'); + $this->assertSame('select * from "users" where ("result"->>\'created_at\')::time >= ?', $builder->toSql()); + $this->assertEquals([0 => '22:00'], $builder->getBindings()); + } + + public function testWherePast() + { + Carbon::setTestNow('2022-04-20 23:45:06.123456'); + + $testDate = Carbon::create('2022-04-20 23:45:06.123456'); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->wherePast('published_at'); + $this->assertSame('select * from "posts" where "published_at" < ?', $builder->toSql()); + $this->assertEquals([0 => $testDate], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->where('id', '=', 1)->orWherePast('published_at'); + $this->assertSame('select * from "posts" where "id" = ? or "published_at" < ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => $testDate], $builder->getBindings()); + } + + public function testWherePastUsesArray() + { + Carbon::setTestNow('2022-04-20 12:34:56.123456'); + + $testDate = Carbon::create('2022-04-20 12:34:56.123456'); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->wherePast(['published_at', 'held_at']); + $this->assertSame('select * from "posts" where "published_at" < ? and "held_at" < ?', $builder->toSql()); + $this->assertEquals([0 => $testDate, 1 => $testDate], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->where('id', '=', 1)->orWherePast(['published_at', 'held_at']); + $this->assertSame('select * from "posts" where "id" = ? or "published_at" < ? or "held_at" < ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => $testDate, 2 => $testDate], $builder->getBindings()); + } + + public function testWhereTodayMySQL() + { + Carbon::setTestNow('2022-04-20 12:34:56.123456'); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('posts')->whereToday('published_at'); + $this->assertSame('select * from `posts` where date(`published_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => '2022-04-20'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('posts')->where('id', '=', 1)->orWhereToday('published_at'); + $this->assertSame('select * from `posts` where `id` = ? or date(`published_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => '2022-04-20'], $builder->getBindings()); + } + + public function testPassingArrayToWhereTodayMySQL() + { + Carbon::setTestNow('2022-04-20 12:34:56.123456'); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('posts')->whereToday(['published_at', 'held_at']); + $this->assertSame('select * from `posts` where date(`published_at`) = ? and date(`held_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => '2022-04-20', 1 => '2022-04-20'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('posts')->where('id', '=', 1)->orWhereToday(['published_at', 'held_at']); + $this->assertSame('select * from `posts` where `id` = ? or date(`published_at`) = ? or date(`held_at`) = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => '2022-04-20', 2 => '2022-04-20'], $builder->getBindings()); + } + + public function testWhereTodaySqlServer() + { + Carbon::setTestNow('2022-04-20 12:34:56.123456'); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('posts')->whereToday('published_at'); + $this->assertSame('select * from [posts] where cast([published_at] as date) = ?', $builder->toSql()); + $this->assertEquals([0 => '2022-04-20'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('posts')->where('id', '=', 1)->orWhereToday('published_at'); + $this->assertSame('select * from [posts] where [id] = ? or cast([published_at] as date) = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => '2022-04-20'], $builder->getBindings()); + } + + public function testPassingArrayToWhereTodaySqlServer() + { + Carbon::setTestNow('2022-04-20 12:34:56.123456'); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('posts')->whereToday(['published_at', 'held_at']); + $this->assertSame('select * from [posts] where cast([published_at] as date) = ? and cast([held_at] as date) = ?', $builder->toSql()); + $this->assertEquals([0 => '2022-04-20', 1 => '2022-04-20'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('posts')->where('id', '=', 1)->orWhereToday(['published_at', 'held_at']); + $this->assertSame('select * from [posts] where [id] = ? or cast([published_at] as date) = ? or cast([held_at] as date) = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => '2022-04-20', 2 => '2022-04-20'], $builder->getBindings()); + } + + public function testWhereFuture() + { + Carbon::setTestNow('2022-04-22 21:01:23.123456'); + + $testDate = Carbon::create('2022-04-22 21:01:23.123456'); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->whereFuture('published_at'); + $this->assertSame('select * from "posts" where "published_at" > ?', $builder->toSql()); + $this->assertEquals([0 => $testDate], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->where('id', '=', 1)->orWhereFuture('published_at'); + $this->assertSame('select * from "posts" where "id" = ? or "published_at" > ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => $testDate], $builder->getBindings()); + } + + public function testPassingArrayToWhereFuture() + { + Carbon::setTestNow('2022-04-22 01:23:45.123456'); + + $testDate = Carbon::create('2022-04-22 01:23:45.123456'); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->whereFuture(['published_at', 'held_at']); + $this->assertSame('select * from "posts" where "published_at" > ? and "held_at" > ?', $builder->toSql()); + $this->assertEquals([0 => $testDate, 1 => $testDate], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->where('id', '=', 1)->orWhereFuture(['published_at', 'held_at']); + $this->assertSame('select * from "posts" where "id" = ? or "published_at" > ? or "held_at" > ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => $testDate, 2 => $testDate], $builder->getBindings()); + } + + public function testWhereLikePostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', 'like', '1'); + $this->assertSame('select * from "users" where "id"::text like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', 'LIKE', '1'); + $this->assertSame('select * from "users" where "id"::text LIKE ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', 'ilike', '1'); + $this->assertSame('select * from "users" where "id"::text ilike ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', 'not like', '1'); + $this->assertSame('select * from "users" where "id"::text not like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', 'not ilike', '1'); + $this->assertSame('select * from "users" where "id"::text not ilike ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + } + + public function testWhereLikeClausePostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1'); + $this->assertSame('select * from "users" where "id"::text ilike ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1', false); + $this->assertSame('select * from "users" where "id"::text ilike ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1', true); + $this->assertSame('select * from "users" where "id"::text like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereNotLike('id', '1'); + $this->assertSame('select * from "users" where "id"::text not ilike ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereNotLike('id', '1', false); + $this->assertSame('select * from "users" where "id"::text not ilike ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereNotLike('id', '1', true); + $this->assertSame('select * from "users" where "id"::text not like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + } + + public function testWhereLikeClauseMysql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1'); + $this->assertSame('select * from `users` where `id` like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1', false); + $this->assertSame('select * from `users` where `id` like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1', true); + $this->assertSame('select * from `users` where `id` like binary ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNotLike('id', '1'); + $this->assertSame('select * from `users` where `id` not like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNotLike('id', '1', false); + $this->assertSame('select * from `users` where `id` not like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNotLike('id', '1', true); + $this->assertSame('select * from `users` where `id` not like binary ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + } + + public function testWhereLikeClauseSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1'); + $this->assertSame('select * from "users" where "id" like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1', true); + $this->assertSame('select * from "users" where "id" glob ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereLike('description', 'Hell* _orld?%', true); + $this->assertSame('select * from "users" where "description" glob ?', $builder->toSql()); + $this->assertEquals([0 => 'Hell[*] ?orld[?]*'], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereNotLike('id', '1'); + $this->assertSame('select * from "users" where "id" not like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereNotLike('description', 'Hell* _orld?%', true); + $this->assertSame('select * from "users" where "description" not glob ?', $builder->toSql()); + $this->assertEquals([0 => 'Hell[*] ?orld[?]*'], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereLike('name', 'John%', true)->whereNotLike('name', '%Doe%', true); + $this->assertSame('select * from "users" where "name" glob ? and "name" not glob ?', $builder->toSql()); + $this->assertEquals([0 => 'John*', 1 => '*Doe*'], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereLike('name', 'John%')->orWhereLike('name', 'Jane%', true); + $this->assertSame('select * from "users" where "name" like ? or "name" glob ?', $builder->toSql()); + $this->assertEquals([0 => 'John%', 1 => 'Jane*'], $builder->getBindings()); + } + + public function testWhereLikeClauseSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1'); + $this->assertSame('select * from [users] where [id] like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereLike('id', '1')->orWhereLike('id', '2'); + $this->assertSame('select * from [users] where [id] like ? or [id] like ?', $builder->toSql()); + $this->assertEquals([0 => '1', 1 => '2'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereNotLike('id', '1'); + $this->assertSame('select * from [users] where [id] not like ?', $builder->toSql()); + $this->assertEquals([0 => '1'], $builder->getBindings()); + } + + public function testWhereDateSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', '=', '2015-12-21'); + $this->assertSame('select * from "users" where strftime(\'%Y-%m-%d\', "created_at") = cast(? as text)', $builder->toSql()); + $this->assertEquals([0 => '2015-12-21'], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', new Raw('NOW()')); + $this->assertSame('select * from "users" where strftime(\'%Y-%m-%d\', "created_at") = cast(NOW() as text)', $builder->toSql()); + } + + public function testWhereDaySqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereDay('created_at', '=', 1); + $this->assertSame('select * from "users" where strftime(\'%d\', "created_at") = cast(? as text)', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testWhereMonthSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereMonth('created_at', '=', 5); + $this->assertSame('select * from "users" where strftime(\'%m\', "created_at") = cast(? as text)', $builder->toSql()); + $this->assertEquals([0 => 5], $builder->getBindings()); + } + + public function testWhereYearSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereYear('created_at', '=', 2014); + $this->assertSame('select * from "users" where strftime(\'%Y\', "created_at") = cast(? as text)', $builder->toSql()); + $this->assertEquals([0 => 2014], $builder->getBindings()); + } + + public function testWhereTimeSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '>=', '22:00'); + $this->assertSame('select * from "users" where strftime(\'%H:%M:%S\', "created_at") >= cast(? as text)', $builder->toSql()); + $this->assertEquals([0 => '22:00'], $builder->getBindings()); + } + + public function testWhereTimeOperatorOptionalSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereTime('created_at', '22:00'); + $this->assertSame('select * from "users" where strftime(\'%H:%M:%S\', "created_at") = cast(? as text)', $builder->toSql()); + $this->assertEquals([0 => '22:00'], $builder->getBindings()); + } + + public function testWhereDateSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', '=', '2015-12-21'); + $this->assertSame('select * from [users] where cast([created_at] as date) = ?', $builder->toSql()); + $this->assertEquals([0 => '2015-12-21'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', new Raw('NOW()')); + $this->assertSame('select * from [users] where cast([created_at] as date) = NOW()', $builder->toSql()); + } + + public function testWhereDaySqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereDay('created_at', '=', 1); + $this->assertSame('select * from [users] where day([created_at]) = ?', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testWhereMonthSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereMonth('created_at', '=', 5); + $this->assertSame('select * from [users] where month([created_at]) = ?', $builder->toSql()); + $this->assertEquals([0 => 5], $builder->getBindings()); + } + + public function testWhereYearSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereYear('created_at', '=', 2014); + $this->assertSame('select * from [users] where year([created_at]) = ?', $builder->toSql()); + $this->assertEquals([0 => 2014], $builder->getBindings()); + } + + public function testWhereBetweens() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetween('id', [1, 2]); + $this->assertSame('select * from "users" where "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetween('id', [[1, 2, 3]]); + $this->assertSame('select * from "users" where "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetween('id', [[1], [2, 3]]); + $this->assertSame('select * from "users" where "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNotBetween('id', [1, 2]); + $this->assertSame('select * from "users" where "id" not between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetween('id', [new Raw(1), new Raw(2)]); + $this->assertSame('select * from "users" where "id" between 1 and 2', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $period = now()->startOfDay()->toPeriod(now()->addDay()->startOfDay()); + $builder->select('*')->from('users')->whereBetween('created_at', $period); + $this->assertSame('select * from "users" where "created_at" between ? and ?', $builder->toSql()); + $this->assertEquals([now()->startOfDay(), now()->addDay()->startOfDay()], $builder->getBindings()); + + // custom long carbon period date + $builder = $this->getBuilder(); + $period = now()->startOfDay()->toPeriod(now()->addMonth()->startOfDay()); + $builder->select('*')->from('users')->whereBetween('created_at', $period); + $this->assertSame('select * from "users" where "created_at" between ? and ?', $builder->toSql()); + $this->assertEquals([now()->startOfDay(), now()->addMonth()->startOfDay()], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetween('id', collect([1, 2])); + $this->assertSame('select * from "users" where "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $subqueryBuilder = $this->getBuilder(); + $subqueryBuilder->select('id')->from('posts')->where('status', 'published')->orderByDesc('created_at')->limit(1); + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetween($subqueryBuilder, collect([1, 2])); + $this->assertSame('select * from "users" where (select "id" from "posts" where "status" = ? order by "created_at" desc limit 1) between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 'published', 1 => 1, 2 => 2], $builder->getBindings()); + } + + public function testOrWhereBetween() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereBetween('id', [3, 5]); + $this->assertSame('select * from "users" where "id" = ? or "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 3, 2 => 5], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereBetween('id', [[3, 4, 5]]); + $this->assertSame('select * from "users" where "id" = ? or "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 3, 2 => 4], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereBetween('id', [[3, 5]]); + $this->assertSame('select * from "users" where "id" = ? or "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 3, 2 => 5], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereBetween('id', [[4], [6, 8]]); + $this->assertSame('select * from "users" where "id" = ? or "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 4, 2 => 6], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereBetween('id', collect([3, 4])); + $this->assertSame('select * from "users" where "id" = ? or "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 3, 2 => 4], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereBetween('id', [new Raw(3), new Raw(4)]); + $this->assertSame('select * from "users" where "id" = ? or "id" between 3 and 4', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testOrWhereNotBetween() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNotBetween('id', [3, 5]); + $this->assertSame('select * from "users" where "id" = ? or "id" not between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 3, 2 => 5], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNotBetween('id', [[3, 4, 5]]); + $this->assertSame('select * from "users" where "id" = ? or "id" not between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 3, 2 => 4], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNotBetween('id', [[3, 5]]); + $this->assertSame('select * from "users" where "id" = ? or "id" not between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 3, 2 => 5], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNotBetween('id', [[4], [6, 8]]); + $this->assertSame('select * from "users" where "id" = ? or "id" not between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 4, 2 => 6], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNotBetween('id', collect([3, 4])); + $this->assertSame('select * from "users" where "id" = ? or "id" not between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 3, 2 => 4], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNotBetween('id', [new Raw(3), new Raw(4)]); + $this->assertSame('select * from "users" where "id" = ? or "id" not between 3 and 4', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testWhereBetweenColumns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetweenColumns('id', ['users.created_at', 'users.updated_at']); + $this->assertSame('select * from "users" where "id" between "users"."created_at" and "users"."updated_at"', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNotBetweenColumns('id', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" not between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetweenColumns('id', [new Raw(1), new Raw(2)]); + $this->assertSame('select * from "users" where "id" between 1 and 2', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $subqueryBuilder = $this->getBuilder(); + $subqueryBuilder->select('created_at')->from('posts')->where('status', 'published')->orderByDesc('created_at')->limit(1); + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetweenColumns($subqueryBuilder, ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where (select "created_at" from "posts" where "status" = ? order by "created_at" desc limit 1) between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => 'published'], $builder->getBindings()); + } + + public function testOrWhereBetweenColumns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereBetweenColumns('id', ['users.created_at', 'users.updated_at']); + $this->assertSame('select * from "users" where "id" = ? or "id" between "users"."created_at" and "users"."updated_at"', $builder->toSql()); + $this->assertEquals([0 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereBetweenColumns('id', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" = ? or "id" between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereBetweenColumns('id', [new Raw(1), new Raw(2)]); + $this->assertSame('select * from "users" where "id" = ? or "id" between 1 and 2', $builder->toSql()); + $this->assertEquals([0 => 2], $builder->getBindings()); + } + + public function testOrWhereNotBetweenColumns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereNotBetweenColumns('id', ['users.created_at', 'users.updated_at']); + $this->assertSame('select * from "users" where "id" = ? or "id" not between "users"."created_at" and "users"."updated_at"', $builder->toSql()); + $this->assertEquals([0 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereNotBetweenColumns('id', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" = ? or "id" not between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereNotBetweenColumns('id', [new Raw(1), new Raw(2)]); + $this->assertSame('select * from "users" where "id" = ? or "id" not between 1 and 2', $builder->toSql()); + $this->assertEquals([0 => 2], $builder->getBindings()); + } + + public function testWhereValueBetween() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereValueBetween('2020-01-01 19:30:00', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where ? between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereValueBetween('2020-01-01 19:30:00', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where ? between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereValueBetween('2020-01-01 19:30:00', [new Raw(1), new Raw(2)]); + $this->assertSame('select * from "users" where ? between 1 and 2', $builder->toSql()); + $this->assertEquals([0 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereValueBetween(new Raw(1), ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where 1 between "created_at" and "updated_at"', $builder->toSql()); + } + + public function testOrWhereValueBetween() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereValueBetween('2020-01-01 19:30:00', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" = ? or ? between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereValueBetween('2020-01-01 19:30:00', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" = ? or ? between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereValueBetween('2020-01-01 19:30:00', [new Raw(1), new Raw(2)]); + $this->assertSame('select * from "users" where "id" = ? or ? between 1 and 2', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereValueBetween(new Raw(1), ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" = ? or 1 between "created_at" and "updated_at"', $builder->toSql()); + } + + public function testWhereValueNotBetween() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereValueNotBetween('2020-01-01 19:30:00', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where ? not between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereValueNotBetween('2020-01-01 19:30:00', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where ? not between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereValueNotBetween('2020-01-01 19:30:00', [new Raw(1), new Raw(2)]); + $this->assertSame('select * from "users" where ? not between 1 and 2', $builder->toSql()); + $this->assertEquals([0 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereValueNotBetween(new Raw(1), ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where 1 not between "created_at" and "updated_at"', $builder->toSql()); + } + + public function testOrWhereValueNotBetween() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereValueNotBetween('2020-01-01 19:30:00', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" = ? or ? not between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereValueNotBetween('2020-01-01 19:30:00', ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" = ? or ? not between "created_at" and "updated_at"', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereValueNotBetween('2020-01-01 19:30:00', [new Raw(1), new Raw(2)]); + $this->assertSame('select * from "users" where "id" = ? or ? not between 1 and 2', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => '2020-01-01 19:30:00'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 2)->orWhereValueNotBetween(new Raw(1), ['created_at', 'updated_at']); + $this->assertSame('select * from "users" where "id" = ? or 1 not between "created_at" and "updated_at"', $builder->toSql()); + } + + public function testBasicOrWheres() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhere('email', '=', 'foo'); + $this->assertSame('select * from "users" where "id" = ? or "email" = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 'foo'], $builder->getBindings()); + } + + public function testBasicOrWhereNot() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orWhereNot('name', 'foo')->orWhereNot('name', '<>', 'bar'); + $this->assertSame('select * from "users" where not "name" = ? or not "name" <> ?', $builder->toSql()); + $this->assertEquals(['foo', 'bar'], $builder->getBindings()); + } + + public function testRawWheres() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereRaw('id = ? or email = ?', [1, 'foo']); + $this->assertSame('select * from "users" where id = ? or email = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 'foo'], $builder->getBindings()); + } + + public function testRawOrWheres() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereRaw('email = ?', ['foo']); + $this->assertSame('select * from "users" where "id" = ? or email = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 'foo'], $builder->getBindings()); + } + + public function testBasicWhereIns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIn('id', [1, 2, 3]); + $this->assertSame('select * from "users" where "id" in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2, 2 => 3], $builder->getBindings()); + + // associative arrays as values: + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIn('id', [ + 'issue' => 45582, + 'id' => 2, + 3, + ]); + $this->assertSame('select * from "users" where "id" in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([0 => 45582, 1 => 2, 2 => 3], $builder->getBindings()); + + // can accept some nested arrays as values. + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIn('id', [ + ['issue' => 45582], + ['id' => 2], + [3], + ]); + $this->assertSame('select * from "users" where "id" in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([0 => 45582, 1 => 2, 2 => 3], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereIn('id', [1, 2, 3]); + $this->assertSame('select * from "users" where "id" = ? or "id" in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 1, 2 => 2, 3 => 3], $builder->getBindings()); + } + + public function testBasicWhereInsException() + { + $this->expectException(InvalidArgumentException::class); + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIn('id', [ + [ + 'a' => 1, + 'b' => 1, + ], + ['c' => 2], + [3], + ]); + } + + public function testBasicWhereNotIns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNotIn('id', [1, 2, 3]); + $this->assertSame('select * from "users" where "id" not in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2, 2 => 3], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNotIn('id', [1, 2, 3]); + $this->assertSame('select * from "users" where "id" = ? or "id" not in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 1, 2 => 2, 3 => 3], $builder->getBindings()); + } + + public function testRawWhereIns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIn('id', [new Raw(1)]); + $this->assertSame('select * from "users" where "id" in (1)', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereIn('id', [new Raw(1)]); + $this->assertSame('select * from "users" where "id" = ? or "id" in (1)', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testEmptyWhereIns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIn('id', []); + $this->assertSame('select * from "users" where 0 = 1', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereIn('id', []); + $this->assertSame('select * from "users" where "id" = ? or 0 = 1', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testEmptyWhereNotIns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNotIn('id', []); + $this->assertSame('select * from "users" where 1 = 1', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNotIn('id', []); + $this->assertSame('select * from "users" where "id" = ? or 1 = 1', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testWhereIntegerInRaw() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIntegerInRaw('id', [ + '1a', 2, Bar::FOO, + ]); + $this->assertSame('select * from "users" where "id" in (1, 2, 5)', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIntegerInRaw('id', [ + ['id' => '1a'], + ['id' => 2], + ['any' => '3'], + ['id' => Bar::FOO], + ]); + $this->assertSame('select * from "users" where "id" in (1, 2, 3, 5)', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + + public function testOrWhereIntegerInRaw() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereIntegerInRaw('id', ['1a', 2]); + $this->assertSame('select * from "users" where "id" = ? or "id" in (1, 2)', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testWhereIntegerNotInRaw() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIntegerNotInRaw('id', ['1a', 2]); + $this->assertSame('select * from "users" where "id" not in (1, 2)', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + + public function testOrWhereIntegerNotInRaw() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereIntegerNotInRaw('id', ['1a', 2]); + $this->assertSame('select * from "users" where "id" = ? or "id" not in (1, 2)', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testEmptyWhereIntegerInRaw() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIntegerInRaw('id', []); + $this->assertSame('select * from "users" where 0 = 1', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + + public function testEmptyWhereIntegerNotInRaw() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIntegerNotInRaw('id', []); + $this->assertSame('select * from "users" where 1 = 1', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + + public function testBasicWhereColumn() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn('first_name', 'last_name')->orWhereColumn('first_name', 'middle_name'); + $this->assertSame('select * from "users" where "first_name" = "last_name" or "first_name" = "middle_name"', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn('updated_at', '>', 'created_at'); + $this->assertSame('select * from "users" where "updated_at" > "created_at"', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + + public function testArrayWhereColumn() + { + $conditions = [ + ['first_name', 'last_name'], + ['updated_at', '>', 'created_at'], + ]; + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn($conditions); + $this->assertSame('select * from "users" where ("first_name" = "last_name" and "updated_at" > "created_at")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + + public function testWhereFulltextMySql() + { + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', 'Hello World'); + $this->assertSame('select * from `users` where match (`body`) against (? in natural language mode)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['expanded' => true]); + $this->assertSame('select * from `users` where match (`body`) against (? in natural language mode with query expansion)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', '+Hello -World', ['mode' => 'boolean']); + $this->assertSame('select * from `users` where match (`body`) against (? in boolean mode)', $builder->toSql()); + $this->assertEquals(['+Hello -World'], $builder->getBindings()); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', '+Hello -World', ['mode' => 'boolean', 'expanded' => true]); + $this->assertSame('select * from `users` where match (`body`) against (? in boolean mode)', $builder->toSql()); + $this->assertEquals(['+Hello -World'], $builder->getBindings()); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText(['body', 'title'], 'Car,Plane'); + $this->assertSame('select * from `users` where match (`body`, `title`) against (? in natural language mode)', $builder->toSql()); + $this->assertEquals(['Car,Plane'], $builder->getBindings()); + } + + public function testWhereFulltextPostgres() + { + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', 'Hello World'); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['language' => 'simple']); + $this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['mode' => 'plain']); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['mode' => 'phrase']); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ phraseto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', '+Hello -World', ['mode' => 'websearch']); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ websearch_to_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['+Hello -World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['language' => 'simple', 'mode' => 'plain']); + $this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText(['body', 'title'], 'Car Plane'); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['Car Plane'], $builder->getBindings()); + + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText(['body', 'title'], 'Air | Plan:* -Car', ['mode' => 'raw']); + $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body") || to_tsvector(\'english\', "title")) @@ to_tsquery(\'english\', ?)', $builder->toSql()); + $this->assertEquals(['Air | Plan:* -Car'], $builder->getBindings()); + } + + public function testWhereAll() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAll(['last_name', 'email'], '%Otwell%'); + $this->assertSame('select * from "users" where ("last_name" = ? and "email" = ?)', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAll(['last_name', 'email'], 'not like', '%Otwell%'); + $this->assertSame('select * from "users" where ("last_name" not like ? and "email" not like ?)', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAll([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where (("last_name" like ?) and ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + } + + public function testOrWhereAll() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAll(['last_name', 'email'], 'like', '%Otwell%'); + $this->assertSame('select * from "users" where "first_name" like ? or ("last_name" like ? and "email" like ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->whereAll(['last_name', 'email'], 'like', '%Otwell%', 'or'); + $this->assertSame('select * from "users" where "first_name" like ? or ("last_name" like ? and "email" like ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAll(['last_name', 'email'], '%Otwell%'); + $this->assertSame('select * from "users" where "first_name" like ? or ("last_name" = ? and "email" = ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAll([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where "first_name" like ? or (("last_name" like ?) and ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + } + + public function testWhereAny() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAny(['last_name', 'email'], 'like', '%Otwell%'); + $this->assertSame('select * from "users" where ("last_name" like ? or "email" like ?)', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAny(['last_name', 'email'], '%Otwell%'); + $this->assertSame('select * from "users" where ("last_name" = ? or "email" = ?)', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAny([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where (("last_name" like ?) or ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + } + + public function testOrWhereAny() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAny(['last_name', 'email'], 'like', '%Otwell%'); + $this->assertSame('select * from "users" where "first_name" like ? or ("last_name" like ? or "email" like ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->whereAny(['last_name', 'email'], 'like', '%Otwell%', 'or'); + $this->assertSame('select * from "users" where "first_name" like ? or ("last_name" like ? or "email" like ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAny(['last_name', 'email'], '%Otwell%'); + $this->assertSame('select * from "users" where "first_name" like ? or ("last_name" = ? or "email" = ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAny([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where "first_name" like ? or (("last_name" like ?) or ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + } + + public function testWhereNone() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNone(['last_name', 'email'], 'like', '%Otwell%'); + $this->assertSame('select * from "users" where not ("last_name" like ? or "email" like ?)', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNone(['last_name', 'email'], 'Otwell'); + $this->assertSame('select * from "users" where not ("last_name" = ? or "email" = ?)', $builder->toSql()); + $this->assertEquals(['Otwell', 'Otwell'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->whereNone(['last_name', 'email'], 'like', '%Otwell%'); + $this->assertSame('select * from "users" where "first_name" like ? and not ("last_name" like ? or "email" like ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNone([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where not (("last_name" like ?) or ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + } + + public function testOrWhereNone() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereNone(['last_name', 'email'], 'like', '%Otwell%'); + $this->assertSame('select * from "users" where "first_name" like ? or not ("last_name" like ? or "email" like ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->whereNone(['last_name', 'email'], 'like', '%Otwell%', 'or'); + $this->assertSame('select * from "users" where "first_name" like ? or not ("last_name" like ? or "email" like ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereNone(['last_name', 'email'], '%Otwell%'); + $this->assertSame('select * from "users" where "first_name" like ? or not ("last_name" = ? or "email" = ?)', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereNone([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where "first_name" like ? or not (("last_name" like ?) or ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + } + + public function testUnions() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1); + $builder->union($this->getBuilder()->select('*')->from('users')->where('id', '=', 2)); + $this->assertSame('(select * from "users" where "id" = ?) union (select * from "users" where "id" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1); + $builder->union($this->getMySqlBuilder()->select('*')->from('users')->where('id', '=', 2)); + $this->assertSame('(select * from `users` where `id` = ?) union (select * from `users` where `id` = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getMysqlBuilder(); + $expectedSql = '(select `a` from `t1` where `a` = ? and `b` = ?) union (select `a` from `t2` where `a` = ? and `b` = ?) order by `a` asc limit 10'; + $union = $this->getMysqlBuilder()->select('a')->from('t2')->where('a', 11)->where('b', 2); + $builder->select('a')->from('t1')->where('a', 10)->where('b', 1)->union($union)->orderBy('a')->limit(10); + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals([0 => 10, 1 => 1, 2 => 11, 3 => 2], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $expectedSql = '(select "name" from "users" where "id" = ?) union (select "name" from "users" where "id" = ?)'; + $builder->select('name')->from('users')->where('id', '=', 1); + $builder->union($this->getPostgresBuilder()->select('name')->from('users')->where('id', '=', 2)); + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $expectedSql = 'select * from (select "name" from "users" where "id" = ?) union select * from (select "name" from "users" where "id" = ?)'; + $builder->select('name')->from('users')->where('id', '=', 1); + $builder->union($this->getSQLiteBuilder()->select('name')->from('users')->where('id', '=', 2)); + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $expectedSql = 'select * from (select [name] from [users] where [id] = ?) as [temp_table] union select * from (select [name] from [users] where [id] = ?) as [temp_table]'; + $builder->select('name')->from('users')->where('id', '=', 1); + $builder->union($this->getSqlServerBuilder()->select('name')->from('users')->where('id', '=', 2)); + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $eloquentBuilder = new EloquentBuilder($this->getBuilder()); + $builder->select('*')->from('users')->where('id', '=', 1)->union($eloquentBuilder->select('*')->from('users')->where('id', '=', 2)); + $this->assertSame('(select * from "users" where "id" = ?) union (select * from "users" where "id" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + } + + public function testUnionAlls() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1); + $builder->unionAll($this->getBuilder()->select('*')->from('users')->where('id', '=', 2)); + $this->assertSame('(select * from "users" where "id" = ?) union all (select * from "users" where "id" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $expectedSql = '(select * from "users" where "id" = ?) union all (select * from "users" where "id" = ?)'; + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1); + $builder->unionAll($this->getBuilder()->select('*')->from('users')->where('id', '=', 2)); + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $eloquentBuilder = new EloquentBuilder($this->getBuilder()); + $builder->select('*')->from('users')->where('id', '=', 1); + $builder->unionAll($eloquentBuilder->select('*')->from('users')->where('id', '=', 2)); + $this->assertSame('(select * from "users" where "id" = ?) union all (select * from "users" where "id" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + } + + public function testMultipleUnions() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1); + $builder->union($this->getBuilder()->select('*')->from('users')->where('id', '=', 2)); + $builder->union($this->getBuilder()->select('*')->from('users')->where('id', '=', 3)); + $this->assertSame('(select * from "users" where "id" = ?) union (select * from "users" where "id" = ?) union (select * from "users" where "id" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2, 2 => 3], $builder->getBindings()); + } + + public function testMultipleUnionAlls() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1); + $builder->unionAll($this->getBuilder()->select('*')->from('users')->where('id', '=', 2)); + $builder->unionAll($this->getBuilder()->select('*')->from('users')->where('id', '=', 3)); + $this->assertSame('(select * from "users" where "id" = ?) union all (select * from "users" where "id" = ?) union all (select * from "users" where "id" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2, 2 => 3], $builder->getBindings()); + } + + public function testUnionOrderBys() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1); + $builder->union($this->getBuilder()->select('*')->from('users')->where('id', '=', 2)); + $builder->orderBy('id', 'desc'); + $this->assertSame('(select * from "users" where "id" = ?) union (select * from "users" where "id" = ?) order by "id" desc', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + } + + public function testUnionLimitsAndOffsets() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users'); + $builder->union($this->getBuilder()->select('*')->from('dogs')); + $builder->offset(5)->limit(10); + $this->assertSame('(select * from "users") union (select * from "dogs") limit 10 offset 5', $builder->toSql()); + + $expectedSql = '(select * from "users") union (select * from "dogs") limit 10 offset 5'; + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users'); + $builder->union($this->getBuilder()->select('*')->from('dogs')); + $builder->offset(5)->limit(10); + $this->assertEquals($expectedSql, $builder->toSql()); + + $expectedSql = '(select * from "users" limit 11) union (select * from "dogs" limit 22) limit 10 offset 5'; + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->limit(11); + $builder->union($this->getBuilder()->select('*')->from('dogs')->limit(22)); + $builder->offset(5)->limit(10); + $this->assertEquals($expectedSql, $builder->toSql()); + } + + public function testUnionWithJoin() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users'); + $builder->union($this->getBuilder()->select('*')->from('dogs')->join('breeds', function ($join) { + $join->on('dogs.breed_id', '=', 'breeds.id') + ->where('breeds.is_native', '=', 1); + })); + $this->assertSame('(select * from "users") union (select * from "dogs" inner join "breeds" on "dogs"."breed_id" = "breeds"."id" and "breeds"."is_native" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testMySqlUnionOrderBys() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1); + $builder->union($this->getMySqlBuilder()->select('*')->from('users')->where('id', '=', 2)); + $builder->orderBy('id', 'desc'); + $this->assertSame('(select * from `users` where `id` = ?) union (select * from `users` where `id` = ?) order by `id` desc', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + } + + public function testMySqlUnionLimitsAndOffsets() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users'); + $builder->union($this->getMySqlBuilder()->select('*')->from('dogs')); + $builder->offset(5)->limit(10); + $this->assertSame('(select * from `users`) union (select * from `dogs`) limit 10 offset 5', $builder->toSql()); + } + + public function testUnionAggregate() + { + $expected = 'select count(*) as aggregate from ((select * from `posts`) union (select * from `videos`)) as `temp_table`'; + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with($expected, [], true); + $builder->getProcessor()->shouldReceive('processSelect')->once(); + $builder->from('posts')->union($this->getMySqlBuilder()->from('videos'))->count(); + + $expected = 'select count(*) as aggregate from ((select `id` from `posts`) union (select `id` from `videos`)) as `temp_table`'; + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with($expected, [], true); + $builder->getProcessor()->shouldReceive('processSelect')->once(); + $builder->from('posts')->select('id')->union($this->getMySqlBuilder()->from('videos')->select('id'))->count(); + + $expected = 'select count(*) as aggregate from ((select * from "posts") union (select * from "videos")) as "temp_table"'; + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with($expected, [], true); + $builder->getProcessor()->shouldReceive('processSelect')->once(); + $builder->from('posts')->union($this->getPostgresBuilder()->from('videos'))->count(); + + $expected = 'select count(*) as aggregate from (select * from (select * from "posts") union select * from (select * from "videos")) as "temp_table"'; + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with($expected, [], true); + $builder->getProcessor()->shouldReceive('processSelect')->once(); + $builder->from('posts')->union($this->getSQLiteBuilder()->from('videos'))->count(); + + $expected = 'select count(*) as aggregate from (select * from (select * from [posts]) as [temp_table] union select * from (select * from [videos]) as [temp_table]) as [temp_table]'; + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with($expected, [], true); + $builder->getProcessor()->shouldReceive('processSelect')->once(); + $builder->from('posts')->union($this->getSqlServerBuilder()->from('videos'))->count(); + } + + public function testHavingAggregate() + { + $expected = 'select count(*) as aggregate from (select (select `count(*)` from `videos` where `posts`.`id` = `videos`.`post_id`) as `videos_count` from `posts` having `videos_count` > ?) as `temp_table`'; + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->getConnection()->shouldReceive('select')->once()->with($expected, [0 => 1], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + + $builder->from('posts')->selectSub(function ($query) { + $query->from('videos')->select('count(*)')->whereColumn('posts.id', '=', 'videos.post_id'); + }, 'videos_count')->having('videos_count', '>', 1); + $builder->count(); + } + + public function testSubSelectWhereIns() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereIn('id', function ($q) { + $q->select('id')->from('users')->where('age', '>', 25)->limit(3); + }); + $this->assertSame('select * from "users" where "id" in (select "id" from "users" where "age" > ? limit 3)', $builder->toSql()); + $this->assertEquals([25], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNotIn('id', function ($q) { + $q->select('id')->from('users')->where('age', '>', 25)->limit(3); + }); + $this->assertSame('select * from "users" where "id" not in (select "id" from "users" where "age" > ? limit 3)', $builder->toSql()); + $this->assertEquals([25], $builder->getBindings()); + } + + public function testBasicWhereNulls() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNull('id'); + $this->assertSame('select * from "users" where "id" is null', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNull('id'); + $this->assertSame('select * from "users" where "id" = ? or "id" is null', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testBasicWhereNullExpressionsMysql() + { + $builder = $this->getMysqlBuilder(); + $builder->select('*')->from('users')->whereNull(new Raw('id')); + $this->assertSame('select * from `users` where id is null', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getMysqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNull(new Raw('id')); + $this->assertSame('select * from `users` where `id` = ? or id is null', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testJsonWhereNullMysql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNull('items->id'); + $this->assertSame('select * from `users` where (json_extract(`items`, \'$."id"\') is null OR json_type(json_extract(`items`, \'$."id"\')) = \'NULL\')', $builder->toSql()); + } + + public function testJsonWhereNotNullMysql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNotNull('items->id'); + $this->assertSame('select * from `users` where (json_extract(`items`, \'$."id"\') is not null AND json_type(json_extract(`items`, \'$."id"\')) != \'NULL\')', $builder->toSql()); + } + + public function testJsonWhereNullExpressionMysql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNull(new Raw('items->id')); + $this->assertSame('select * from `users` where (json_extract(`items`, \'$."id"\') is null OR json_type(json_extract(`items`, \'$."id"\')) = \'NULL\')', $builder->toSql()); + } + + public function testJsonWhereNotNullExpressionMysql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereNotNull(new Raw('items->id')); + $this->assertSame('select * from `users` where (json_extract(`items`, \'$."id"\') is not null AND json_type(json_extract(`items`, \'$."id"\')) != \'NULL\')', $builder->toSql()); + } + + public function testArrayWhereNulls() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNull(['id', 'expires_at']); + $this->assertSame('select * from "users" where "id" is null and "expires_at" is null', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereNull(['id', 'expires_at']); + $this->assertSame('select * from "users" where "id" = ? or "id" is null or "expires_at" is null', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testBasicWhereNotNulls() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNotNull('id'); + $this->assertSame('select * from "users" where "id" is not null', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '>', 1)->orWhereNotNull('id'); + $this->assertSame('select * from "users" where "id" > ? or "id" is not null', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testArrayWhereNotNulls() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNotNull(['id', 'expires_at']); + $this->assertSame('select * from "users" where "id" is not null and "expires_at" is not null', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', '>', 1)->orWhereNotNull(['id', 'expires_at']); + $this->assertSame('select * from "users" where "id" > ? or "id" is not null or "expires_at" is not null', $builder->toSql()); + $this->assertEquals([0 => 1], $builder->getBindings()); + } + + public function testGroupBys() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->groupBy('email'); + $this->assertSame('select * from "users" group by "email"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->groupBy('id', 'email'); + $this->assertSame('select * from "users" group by "id", "email"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->groupBy(['id', 'email']); + $this->assertSame('select * from "users" group by "id", "email"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->groupBy(new Raw('DATE(created_at)')); + $this->assertSame('select * from "users" group by DATE(created_at)', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->groupByRaw('DATE(created_at), ? DESC', ['foo']); + $this->assertSame('select * from "users" group by DATE(created_at), ? DESC', $builder->toSql()); + $this->assertEquals(['foo'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->havingRaw('?', ['havingRawBinding'])->groupByRaw('?', ['groupByRawBinding'])->whereRaw('?', ['whereRawBinding']); + $this->assertEquals(['whereRawBinding', 'groupByRawBinding', 'havingRawBinding'], $builder->getBindings()); + } + + public function testOrderBys() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderBy('email')->orderBy('age', 'desc'); + $this->assertSame('select * from "users" order by "email" asc, "age" desc', $builder->toSql()); + + $builder->orders = null; + $this->assertSame('select * from "users"', $builder->toSql()); + + $builder->orders = []; + $this->assertSame('select * from "users"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderBy('email')->orderByRaw('"age" ? desc', ['foo']); + $this->assertSame('select * from "users" order by "email" asc, "age" ? desc', $builder->toSql()); + $this->assertEquals(['foo'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderByDesc('name'); + $this->assertSame('select * from "users" order by "name" desc', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->where('public', 1) + ->unionAll($this->getBuilder()->select('*')->from('videos')->where('public', 1)) + ->orderByRaw('field(category, ?, ?) asc', ['news', 'opinion']); + $this->assertSame('(select * from "posts" where "public" = ?) union all (select * from "videos" where "public" = ?) order by field(category, ?, ?) asc', $builder->toSql()); + $this->assertEquals([1, 1, 'news', 'opinion'], $builder->getBindings()); + } + + public function testLatest() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->latest(); + $this->assertSame('select * from "users" order by "created_at" desc', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->latest()->limit(1); + $this->assertSame('select * from "users" order by "created_at" desc limit 1', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->latest('updated_at'); + $this->assertSame('select * from "users" order by "updated_at" desc', $builder->toSql()); + } + + public function testOldest() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->oldest(); + $this->assertSame('select * from "users" order by "created_at" asc', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->oldest()->limit(1); + $this->assertSame('select * from "users" order by "created_at" asc limit 1', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->oldest('updated_at'); + $this->assertSame('select * from "users" order by "updated_at" asc', $builder->toSql()); + } + + public function testInRandomOrderMySql() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->inRandomOrder(); + $this->assertSame('select * from "users" order by RANDOM()', $builder->toSql()); + } + + public function testInRandomOrderPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->inRandomOrder(); + $this->assertSame('select * from "users" order by RANDOM()', $builder->toSql()); + } + + public function testInRandomOrderSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->inRandomOrder(); + $this->assertSame('select * from [users] order by NEWID()', $builder->toSql()); + } + + public function testOrderBysSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderBy('email')->orderBy('age', 'desc'); + $this->assertSame('select * from [users] order by [email] asc, [age] desc', $builder->toSql()); + + $builder->orders = null; + $this->assertSame('select * from [users]', $builder->toSql()); + + $builder->orders = []; + $this->assertSame('select * from [users]', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderBy('email'); + $this->assertSame('select * from [users] order by [email] asc', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderByDesc('name'); + $this->assertSame('select * from [users] order by [name] desc', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderByRaw('[age] asc'); + $this->assertSame('select * from [users] order by [age] asc', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->orderBy('email')->orderByRaw('[age] ? desc', ['foo']); + $this->assertSame('select * from [users] order by [email] asc, [age] ? desc', $builder->toSql()); + $this->assertEquals(['foo'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->offset(25)->limit(10)->orderByRaw('[email] desc'); + $this->assertSame('select * from [users] order by [email] desc offset 25 rows fetch next 10 rows only', $builder->toSql()); + } + + public function testReorder() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderBy('name'); + $this->assertSame('select * from "users" order by "name" asc', $builder->toSql()); + $builder->reorder(); + $this->assertSame('select * from "users"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderBy('name'); + $this->assertSame('select * from "users" order by "name" asc', $builder->toSql()); + $builder->reorder('email', 'desc'); + $this->assertSame('select * from "users" order by "email" desc', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('first'); + $builder->union($this->getBuilder()->select('*')->from('second')); + $builder->orderBy('name'); + $this->assertSame('(select * from "first") union (select * from "second") order by "name" asc', $builder->toSql()); + $builder->reorder(); + $this->assertSame('(select * from "first") union (select * from "second")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderByRaw('?', [true]); + $this->assertEquals([true], $builder->getBindings()); + $builder->reorder(); + $this->assertEquals([], $builder->getBindings()); + } + + public function testOrderBySubQueries() + { + $expected = 'select * from "users" order by (select "created_at" from "logins" where "user_id" = "users"."id" limit 1)'; + $subQuery = function ($query) { + return $query->select('created_at')->from('logins')->whereColumn('user_id', 'users.id')->limit(1); + }; + + $builder = $this->getBuilder()->select('*')->from('users')->orderBy($subQuery); + $this->assertSame("$expected asc", $builder->toSql()); + + $builder = $this->getBuilder()->select('*')->from('users')->orderBy($subQuery, 'desc'); + $this->assertSame("$expected desc", $builder->toSql()); + + $builder = $this->getBuilder()->select('*')->from('users')->orderByDesc($subQuery); + $this->assertSame("$expected desc", $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('posts')->where('public', 1) + ->unionAll($this->getBuilder()->select('*')->from('videos')->where('public', 1)) + ->orderBy($this->getBuilder()->selectRaw('field(category, ?, ?)', ['news', 'opinion'])); + $this->assertSame('(select * from "posts" where "public" = ?) union all (select * from "videos" where "public" = ?) order by (select field(category, ?, ?)) asc', $builder->toSql()); + $this->assertEquals([1, 1, 'news', 'opinion'], $builder->getBindings()); + } + + public function testOrderByInvalidDirectionParam() + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderBy('age', 'asec'); + } + + public function testHavings() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->having('email', '>', 1); + $this->assertSame('select * from "users" having "email" > ?', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users') + ->orHaving('email', '=', 'test@example.com') + ->orHaving('email', '=', 'test2@example.com'); + $this->assertSame('select * from "users" having "email" = ? or "email" = ?', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->groupBy('email')->having('email', '>', 1); + $this->assertSame('select * from "users" group by "email" having "email" > ?', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('email as foo_email')->from('users')->having('foo_email', '>', 1); + $this->assertSame('select "email" as "foo_email" from "users" having "foo_email" > ?', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select(['category', new Raw('count(*) as "total"')])->from('item')->where('department', '=', 'popular')->groupBy('category')->having('total', '>', new Raw('3')); + $this->assertSame('select "category", count(*) as "total" from "item" where "department" = ? group by "category" having "total" > 3', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select(['category', new Raw('count(*) as "total"')])->from('item')->where('department', '=', 'popular')->groupBy('category')->having('total', '>', 3); + $this->assertSame('select "category", count(*) as "total" from "item" where "department" = ? group by "category" having "total" > ?', $builder->toSql()); + } + + public function testNestedHavings() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->having('email', '=', 'foo')->orHaving(function ($q) { + $q->having('name', '=', 'bar')->having('age', '=', 25); + }); + $this->assertSame('select * from "users" having "email" = ? or ("name" = ? and "age" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'foo', 1 => 'bar', 2 => 25], $builder->getBindings()); + } + + public function testNestedHavingBindings() + { + $builder = $this->getBuilder(); + $builder->having('email', '=', 'foo')->having(function ($q) { + $q->selectRaw('?', ['ignore'])->having('name', '=', 'bar'); + }); + $this->assertEquals([0 => 'foo', 1 => 'bar'], $builder->getBindings()); + } + + public function testHavingBetweens() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->havingBetween('id', [1, 2, 3]); + $this->assertSame('select * from "users" having "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->havingBetween('id', [[1, 2], [3, 4]]); + $this->assertSame('select * from "users" having "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + } + + public function testHavingNull() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->havingNull('email'); + $this->assertSame('select * from "users" having "email" is null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users') + ->havingNull('email') + ->havingNull('phone'); + $this->assertSame('select * from "users" having "email" is null and "phone" is null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users') + ->orHavingNull('email') + ->orHavingNull('phone'); + $this->assertSame('select * from "users" having "email" is null or "phone" is null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->groupBy('email')->havingNull('email'); + $this->assertSame('select * from "users" group by "email" having "email" is null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('email as foo_email')->from('users')->havingNull('foo_email'); + $this->assertSame('select "email" as "foo_email" from "users" having "foo_email" is null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select(['category', new Raw('count(*) as "total"')])->from('item')->where('department', '=', 'popular')->groupBy('category')->havingNull('total'); + $this->assertSame('select "category", count(*) as "total" from "item" where "department" = ? group by "category" having "total" is null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select(['category', new Raw('count(*) as "total"')])->from('item')->where('department', '=', 'popular')->groupBy('category')->havingNull('total'); + $this->assertSame('select "category", count(*) as "total" from "item" where "department" = ? group by "category" having "total" is null', $builder->toSql()); + } + + public function testHavingNotNull() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->havingNotNull('email'); + $this->assertSame('select * from "users" having "email" is not null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users') + ->havingNotNull('email') + ->havingNotNull('phone'); + $this->assertSame('select * from "users" having "email" is not null and "phone" is not null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users') + ->orHavingNotNull('email') + ->orHavingNotNull('phone'); + $this->assertSame('select * from "users" having "email" is not null or "phone" is not null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->groupBy('email')->havingNotNull('email'); + $this->assertSame('select * from "users" group by "email" having "email" is not null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('email as foo_email')->from('users')->havingNotNull('foo_email'); + $this->assertSame('select "email" as "foo_email" from "users" having "foo_email" is not null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select(['category', new Raw('count(*) as "total"')])->from('item')->where('department', '=', 'popular')->groupBy('category')->havingNotNull('total'); + $this->assertSame('select "category", count(*) as "total" from "item" where "department" = ? group by "category" having "total" is not null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select(['category', new Raw('count(*) as "total"')])->from('item')->where('department', '=', 'popular')->groupBy('category')->havingNotNull('total'); + $this->assertSame('select "category", count(*) as "total" from "item" where "department" = ? group by "category" having "total" is not null', $builder->toSql()); + } + + public function testHavingExpression() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->having( + new class() implements ConditionExpression + { + public function getValue(\Illuminate\Database\Grammar $grammar) + { + return '1 = 1'; + } + } + ); + $this->assertSame('select * from "users" having 1 = 1', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + + public function testHavingShortcut() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->having('email', 1)->orHaving('email', 2); + $this->assertSame('select * from "users" having "email" = ? or "email" = ?', $builder->toSql()); + } + + public function testHavingFollowedBySelectGet() + { + $builder = $this->getBuilder(); + $query = 'select "category", count(*) as "total" from "item" where "department" = ? group by "category" having "total" > ?'; + $builder->getConnection()->shouldReceive('select')->once()->with($query, ['popular', 3], true)->andReturn([['category' => 'rock', 'total' => 5]]); + $builder->getProcessor()->shouldReceive('processSelect')->andReturnUsing(function ($builder, $results) { + return $results; + }); + $builder->from('item'); + $result = $builder->select(['category', new Raw('count(*) as "total"')])->where('department', '=', 'popular')->groupBy('category')->having('total', '>', 3)->get(); + $this->assertEquals([['category' => 'rock', 'total' => 5]], $result->all()); + + // Using \Raw value + $builder = $this->getBuilder(); + $query = 'select "category", count(*) as "total" from "item" where "department" = ? group by "category" having "total" > 3'; + $builder->getConnection()->shouldReceive('select')->once()->with($query, ['popular'], true)->andReturn([['category' => 'rock', 'total' => 5]]); + $builder->getProcessor()->shouldReceive('processSelect')->andReturnUsing(function ($builder, $results) { + return $results; + }); + $builder->from('item'); + $result = $builder->select(['category', new Raw('count(*) as "total"')])->where('department', '=', 'popular')->groupBy('category')->having('total', '>', new Raw('3'))->get(); + $this->assertEquals([['category' => 'rock', 'total' => 5]], $result->all()); + } + + public function testRawHavings() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->havingRaw('user_foo < user_bar'); + $this->assertSame('select * from "users" having user_foo < user_bar', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->having('baz', '=', 1)->orHavingRaw('user_foo < user_bar'); + $this->assertSame('select * from "users" having "baz" = ? or user_foo < user_bar', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->havingBetween('last_login_date', ['2018-11-16', '2018-12-16'])->orHavingRaw('user_foo < user_bar'); + $this->assertSame('select * from "users" having "last_login_date" between ? and ? or user_foo < user_bar', $builder->toSql()); + } + + public function testLimitsAndOffsets() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->offset(5)->limit(10); + $this->assertSame('select * from "users" limit 10 offset 5', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->limit(null); + $this->assertSame('select * from "users"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->limit(0); + $this->assertSame('select * from "users" limit 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->offset(5)->limit(10); + $this->assertSame('select * from "users" limit 10 offset 5', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->offset(0)->limit(0); + $this->assertSame('select * from "users" limit 0 offset 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->offset(-5)->limit(-10); + $this->assertSame('select * from "users" offset 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->offset(null)->limit(null); + $this->assertSame('select * from "users" offset 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->offset(5)->limit(null); + $this->assertSame('select * from "users" offset 5', $builder->toSql()); + } + + public function testForPage() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPage(2, 15); + $this->assertSame('select * from "users" limit 15 offset 15', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPage(0, 15); + $this->assertSame('select * from "users" limit 15 offset 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPage(-2, 15); + $this->assertSame('select * from "users" limit 15 offset 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPage(2, 0); + $this->assertSame('select * from "users" limit 0 offset 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPage(0, 0); + $this->assertSame('select * from "users" limit 0 offset 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPage(-2, 0); + $this->assertSame('select * from "users" limit 0 offset 0', $builder->toSql()); + } + + public function testForPageBeforeId() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPageBeforeId(15, null); + $this->assertSame('select * from "users" where "id" is not null order by "id" desc limit 15', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPageBeforeId(15, 0); + $this->assertSame('select * from "users" where "id" < ? order by "id" desc limit 15', $builder->toSql()); + } + + public function testForPageAfterId() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPageAfterId(15, null); + $this->assertSame('select * from "users" where "id" is not null order by "id" asc limit 15', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPageAfterId(15, 0); + $this->assertSame('select * from "users" where "id" > ? order by "id" asc limit 15', $builder->toSql()); + } + + public function testGetCountForPaginationWithBindings() + { + $builder = $this->getBuilder(); + $builder->from('users')->selectSub(function ($q) { + $q->select('body')->from('posts')->where('id', 4); + }, 'post'); + + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + + $count = $builder->getCountForPagination(); + $this->assertEquals(1, $count); + $this->assertEquals([4], $builder->getBindings()); + } + + public function testGetCountForPaginationWithColumnAliases() + { + $builder = $this->getBuilder(); + $columns = ['body as post_body', 'teaser', 'posts.created as published']; + $builder->from('posts')->select($columns); + + $builder->getConnection()->shouldReceive('select')->once()->with('select count("body", "teaser", "posts"."created") as aggregate from "posts"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + + $count = $builder->getCountForPagination($columns); + $this->assertEquals(1, $count); + } + + public function testGetCountForPaginationWithUnion() + { + $builder = $this->getBuilder(); + $builder->from('posts')->select('id')->union($this->getBuilder()->from('videos')->select('id')); + + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from ((select "id" from "posts") union (select "id" from "videos")) as "temp_table"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + + $count = $builder->getCountForPagination(); + $this->assertEquals(1, $count); + } + + public function testGetCountForPaginationWithUnionOrders() + { + $builder = $this->getBuilder(); + $builder->from('posts')->select('id')->union($this->getBuilder()->from('videos')->select('id'))->latest(); + + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from ((select "id" from "posts") union (select "id" from "videos")) as "temp_table"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + + $count = $builder->getCountForPagination(); + $this->assertEquals(1, $count); + } + + public function testGetCountForPaginationWithUnionLimitAndOffset() + { + $builder = $this->getBuilder(); + $builder->from('posts')->select('id')->union($this->getBuilder()->from('videos')->select('id'))->limit(15)->offset(1); + + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from ((select "id" from "posts") union (select "id" from "videos")) as "temp_table"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + + $count = $builder->getCountForPagination(); + $this->assertEquals(1, $count); + } + + public function testWhereShortcut() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('id', 1)->orWhere('name', 'foo'); + $this->assertSame('select * from "users" where "id" = ? or "name" = ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 'foo'], $builder->getBindings()); + } + + public function testOrWheresHaveConsistentResults() + { + $queries = []; + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhere(['foo' => 1, 'bar' => 2]); + $queries[] = $builder->toSql(); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhere([['foo', 1], ['bar', 2]]); + $queries[] = $builder->toSql(); + + $this->assertSame([ + 'select * from "users" where "xxxx" = ? or ("foo" = ? or "bar" = ?)', + 'select * from "users" where "xxxx" = ? or ("foo" = ? or "bar" = ?)', + ], $queries); + + $queries = []; + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereColumn(['foo' => '_foo', 'bar' => '_bar']); + $queries[] = $builder->toSql(); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereColumn([['foo', '_foo'], ['bar', '_bar']]); + $queries[] = $builder->toSql(); + + $this->assertSame([ + 'select * from "users" where "xxxx" = ? or ("foo" = "_foo" or "bar" = "_bar")', + 'select * from "users" where "xxxx" = ? or ("foo" = "_foo" or "bar" = "_bar")', + ], $queries); + } + + public function testWhereWithArrayConditions() + { + // where(key, value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where([['foo', 1], ['bar', 2]]); + $this->assertSame('select * from "users" where ("foo" = ? and "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where([['foo', 1], ['bar', 2]], boolean: 'or'); + $this->assertSame('select * from "users" where ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where([['foo', 1], ['bar', 2]], boolean: 'and'); + $this->assertSame('select * from "users" where ("foo" = ? and "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where(['foo' => 1, 'bar' => 2]); + $this->assertSame('select * from "users" where ("foo" = ? and "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where(['foo' => 1, 'bar' => 2], boolean: 'or'); + $this->assertSame('select * from "users" where ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where(['foo' => 1, 'bar' => 2], boolean: 'and'); + $this->assertSame('select * from "users" where ("foo" = ? and "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + // where(key, <, value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where([['foo', 1], ['bar', '<', 2]]); + $this->assertSame('select * from "users" where ("foo" = ? and "bar" < ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where([['foo', 1], ['bar', '<', 2]], boolean: 'or'); + $this->assertSame('select * from "users" where ("foo" = ? or "bar" < ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where([['foo', 1], ['bar', '<', 2]], boolean: 'and'); + $this->assertSame('select * from "users" where ("foo" = ? and "bar" < ?)', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + // whereNot(key, value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot([['foo', 1], ['bar', 2]]); + $this->assertSame('select * from "users" where not (("foo" = ? and "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot([['foo', 1], ['bar', 2]], boolean: 'or'); + $this->assertSame('select * from "users" where not (("foo" = ? or "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot([['foo', 1], ['bar', 2]], boolean: 'and'); + $this->assertSame('select * from "users" where not (("foo" = ? and "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot(['foo' => 1, 'bar' => 2]); + $this->assertSame('select * from "users" where not (("foo" = ? and "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot(['foo' => 1, 'bar' => 2], boolean: 'or'); + $this->assertSame('select * from "users" where not (("foo" = ? or "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot(['foo' => 1, 'bar' => 2], boolean: 'and'); + $this->assertSame('select * from "users" where not (("foo" = ? and "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + // whereNot(key, <, value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot([['foo', 1], ['bar', '<', 2]]); + $this->assertSame('select * from "users" where not (("foo" = ? and "bar" < ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot([['foo', 1], ['bar', '<', 2]], boolean: 'or'); + $this->assertSame('select * from "users" where not (("foo" = ? or "bar" < ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot([['foo', 1], ['bar', '<', 2]], boolean: 'and'); + $this->assertSame('select * from "users" where not (("foo" = ? and "bar" < ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + // whereColumn(col1, col2) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn([['foo', '_foo'], ['bar', '_bar']]); + $this->assertSame('select * from "users" where ("foo" = "_foo" and "bar" = "_bar")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn([['foo', '_foo'], ['bar', '_bar']], boolean: 'or'); + $this->assertSame('select * from "users" where ("foo" = "_foo" or "bar" = "_bar")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn([['foo', '_foo'], ['bar', '_bar']], boolean: 'and'); + $this->assertSame('select * from "users" where ("foo" = "_foo" and "bar" = "_bar")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn(['foo' => '_foo', 'bar' => '_bar']); + $this->assertSame('select * from "users" where ("foo" = "_foo" and "bar" = "_bar")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn(['foo' => '_foo', 'bar' => '_bar'], boolean: 'or'); + $this->assertSame('select * from "users" where ("foo" = "_foo" or "bar" = "_bar")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn(['foo' => '_foo', 'bar' => '_bar'], boolean: 'and'); + $this->assertSame('select * from "users" where ("foo" = "_foo" and "bar" = "_bar")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + // whereColumn(col1, <, col2) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn([['foo', '_foo'], ['bar', '<', '_bar']]); + $this->assertSame('select * from "users" where ("foo" = "_foo" and "bar" < "_bar")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn([['foo', '_foo'], ['bar', '<', '_bar']], boolean: 'or'); + $this->assertSame('select * from "users" where ("foo" = "_foo" or "bar" < "_bar")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereColumn([['foo', '_foo'], ['bar', '<', '_bar']], boolean: 'and'); + $this->assertSame('select * from "users" where ("foo" = "_foo" and "bar" < "_bar")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + + // whereAll([...keys], value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAll(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where ("foo" = ? and "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAll(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where ("foo" = ? and "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => 2], $builder->getBindings()); + + // whereAny([...keys], value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAny(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAny(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => 2], $builder->getBindings()); + + // whereNone([...keys], value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNone(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where not ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNone(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where not ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 2, 1 => 2], $builder->getBindings()); + + // where()->orWhere(key, value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhere([['foo', 1], ['bar', 2]]); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 1, 2 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhere(['foo' => 1, 'bar' => 2]); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 1, 2 => 2], $builder->getBindings()); + + // where()->orWhere(key, <, value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhere([['foo', 1], ['bar', '<', 2]]); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = ? or "bar" < ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 1, 2 => 2], $builder->getBindings()); + + // where()->orWhereColumn(col1, col2) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereColumn([['foo', '_foo'], ['bar', '_bar']]); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = "_foo" or "bar" = "_bar")', $builder->toSql()); + $this->assertEquals([0 => 'xxxx'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereColumn(['foo' => '_foo', 'bar' => '_bar']); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = "_foo" or "bar" = "_bar")', $builder->toSql()); + $this->assertEquals([0 => 'xxxx'], $builder->getBindings()); + + // where()->orWhere(key, <, value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhere([['foo', 1], ['bar', '<', 2]]); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = ? or "bar" < ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 1, 2 => 2], $builder->getBindings()); + + // where()->orWhereNot(key, value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereNot([['foo', 1], ['bar', 2]]); + $this->assertSame('select * from "users" where "xxxx" = ? or not (("foo" = ? or "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 1, 2 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereNot(['foo' => 1, 'bar' => 2]); + $this->assertSame('select * from "users" where "xxxx" = ? or not (("foo" = ? or "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 1, 2 => 2], $builder->getBindings()); + + // where()->orWhereNot(key, <, value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereNot([['foo', 1], ['bar', '<', 2]]); + $this->assertSame('select * from "users" where "xxxx" = ? or not (("foo" = ? or "bar" < ?))', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 1, 2 => 2], $builder->getBindings()); + + // where()->orWhereAll([...keys], value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereAll(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = ? and "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 2, 2 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereAll(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = ? and "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 2, 2 => 2], $builder->getBindings()); + + // where()->orWhereAny([...keys], value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereAny(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 2, 2 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereAny(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where "xxxx" = ? or ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 2, 2 => 2], $builder->getBindings()); + + // where()->orWhereNone([...keys], value) + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereNone(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where "xxxx" = ? or not ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 2, 2 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('xxxx', 'xxxx')->orWhereNone(['foo', 'bar'], 2); + $this->assertSame('select * from "users" where "xxxx" = ? or not ("foo" = ? or "bar" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'xxxx', 1 => 2, 2 => 2], $builder->getBindings()); + } + + public function testNestedWheres() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('email', '=', 'foo')->orWhere(function ($q) { + $q->where('name', '=', 'bar')->where('age', '=', 25); + }); + $this->assertSame('select * from "users" where "email" = ? or ("name" = ? and "age" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'foo', 1 => 'bar', 2 => 25], $builder->getBindings()); + } + + public function testNestedWhereBindings() + { + $builder = $this->getBuilder(); + $builder->where('email', '=', 'foo')->where(function ($q) { + $q->selectRaw('?', ['ignore'])->where('name', '=', 'bar'); + }); + $this->assertEquals([0 => 'foo', 1 => 'bar'], $builder->getBindings()); + } + + public function testWhereNot() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot(function ($q) { + $q->where('email', '=', 'foo'); + }); + $this->assertSame('select * from "users" where not ("email" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'foo'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('name', '=', 'bar')->whereNot(function ($q) { + $q->where('email', '=', 'foo'); + }); + $this->assertSame('select * from "users" where "name" = ? and not ("email" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'bar', 1 => 'foo'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('name', '=', 'bar')->orWhereNot(function ($q) { + $q->where('email', '=', 'foo'); + }); + $this->assertSame('select * from "users" where "name" = ? or not ("email" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'bar', 1 => 'foo'], $builder->getBindings()); + } + + public function testIncrementManyArgumentValidation1() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Non-numeric value passed as increment amount for column: \'col\'.'); + $builder = $this->getBuilder(); + $builder->from('users')->incrementEach(['col' => 'a']); + } + + public function testIncrementManyArgumentValidation2() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Non-associative array passed to incrementEach method.'); + $builder = $this->getBuilder(); + $builder->from('users')->incrementEach([11 => 11]); + } + + public function testWhereNotWithArrayConditions() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot([['foo', 1], ['bar', 2]]); + $this->assertSame('select * from "users" where not (("foo" = ? and "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot(['foo' => 1, 'bar' => 2]); + $this->assertSame('select * from "users" where not (("foo" = ? and "bar" = ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNot([['foo', 1], ['bar', '<', 2]]); + $this->assertSame('select * from "users" where not (("foo" = ? and "bar" < ?))', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + } + + public function testFullSubSelects() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('email', '=', 'foo')->orWhere('id', '=', function ($q) { + $q->select(new Raw('max(id)'))->from('users')->where('email', '=', 'bar'); + }); + + $this->assertSame('select * from "users" where "email" = ? or "id" = (select max(id) from "users" where "email" = ?)', $builder->toSql()); + $this->assertEquals([0 => 'foo', 1 => 'bar'], $builder->getBindings()); + } + + public function testWhereExists() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->whereExists(function ($q) { + $q->select('*')->from('products')->where('products.id', '=', new Raw('"orders"."id"')); + }); + $this->assertSame('select * from "orders" where exists (select * from "products" where "products"."id" = "orders"."id")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->whereNotExists(function ($q) { + $q->select('*')->from('products')->where('products.id', '=', new Raw('"orders"."id"')); + }); + $this->assertSame('select * from "orders" where not exists (select * from "products" where "products"."id" = "orders"."id")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->where('id', '=', 1)->orWhereExists(function ($q) { + $q->select('*')->from('products')->where('products.id', '=', new Raw('"orders"."id"')); + }); + $this->assertSame('select * from "orders" where "id" = ? or exists (select * from "products" where "products"."id" = "orders"."id")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->where('id', '=', 1)->orWhereNotExists(function ($q) { + $q->select('*')->from('products')->where('products.id', '=', new Raw('"orders"."id"')); + }); + $this->assertSame('select * from "orders" where "id" = ? or not exists (select * from "products" where "products"."id" = "orders"."id")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->whereExists( + $this->getBuilder()->select('*')->from('products')->where('products.id', '=', new Raw('"orders"."id"')) + ); + $this->assertSame('select * from "orders" where exists (select * from "products" where "products"."id" = "orders"."id")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->whereNotExists( + $this->getBuilder()->select('*')->from('products')->where('products.id', '=', new Raw('"orders"."id"')) + ); + $this->assertSame('select * from "orders" where not exists (select * from "products" where "products"."id" = "orders"."id")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->where('id', '=', 1)->orWhereExists( + $this->getBuilder()->select('*')->from('products')->where('products.id', '=', new Raw('"orders"."id"')) + ); + $this->assertSame('select * from "orders" where "id" = ? or exists (select * from "products" where "products"."id" = "orders"."id")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->where('id', '=', 1)->orWhereNotExists( + $this->getBuilder()->select('*')->from('products')->where('products.id', '=', new Raw('"orders"."id"')) + ); + $this->assertSame('select * from "orders" where "id" = ? or not exists (select * from "products" where "products"."id" = "orders"."id")', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->whereExists( + (new EloquentBuilder($this->getBuilder()))->select('*')->from('products')->where('products.id', '=', new Raw('"orders"."id"')) + ); + $this->assertSame('select * from "orders" where exists (select * from "products" where "products"."id" = "orders"."id")', $builder->toSql()); + } + + public function testBasicJoins() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', 'users.id', 'contacts.id'); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->leftJoin('photos', 'users.id', '=', 'photos.id'); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" left join "photos" on "users"."id" = "photos"."id"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->leftJoinWhere('photos', 'users.id', '=', 'bar')->joinWhere('photos', 'users.id', '=', 'foo'); + $this->assertSame('select * from "users" left join "photos" on "users"."id" = ? inner join "photos" on "users"."id" = ?', $builder->toSql()); + $this->assertEquals(['bar', 'foo'], $builder->getBindings()); + } + + public function testCrossJoins() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('sizes')->crossJoin('colors'); + $this->assertSame('select * from "sizes" cross join "colors"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('tableB')->join('tableA', 'tableA.column1', '=', 'tableB.column2', 'cross'); + $this->assertSame('select * from "tableB" cross join "tableA" on "tableA"."column1" = "tableB"."column2"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('tableB')->crossJoin('tableA', 'tableA.column1', '=', 'tableB.column2'); + $this->assertSame('select * from "tableB" cross join "tableA" on "tableA"."column1" = "tableB"."column2"', $builder->toSql()); + } + + public function testCrossJoinSubs() + { + $builder = $this->getBuilder(); + $builder->selectRaw('(sale / overall.sales) * 100 AS percent_of_total')->from('sales')->crossJoinSub($this->getBuilder()->selectRaw('SUM(sale) AS sales')->from('sales'), 'overall'); + $this->assertSame('select (sale / overall.sales) * 100 AS percent_of_total from "sales" cross join (select SUM(sale) AS sales from "sales") as "overall"', $builder->toSql()); + } + + public function testComplexJoin() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->orOn('users.name', '=', 'contacts.name'); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" or "users"."name" = "contacts"."name"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->where('users.id', '=', 'foo')->orWhere('users.name', '=', 'bar'); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = ? or "users"."name" = ?', $builder->toSql()); + $this->assertEquals(['foo', 'bar'], $builder->getBindings()); + + // Run the assertions again + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = ? or "users"."name" = ?', $builder->toSql()); + $this->assertEquals(['foo', 'bar'], $builder->getBindings()); + } + + public function testJoinWhereNull() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->whereNull('contacts.deleted_at'); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" and "contacts"."deleted_at" is null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->orWhereNull('contacts.deleted_at'); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" or "contacts"."deleted_at" is null', $builder->toSql()); + } + + public function testJoinWhereNotNull() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->whereNotNull('contacts.deleted_at'); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" and "contacts"."deleted_at" is not null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->orWhereNotNull('contacts.deleted_at'); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" or "contacts"."deleted_at" is not null', $builder->toSql()); + } + + public function testJoinWhereIn() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->whereIn('contacts.name', [48, 'baz', null]); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" and "contacts"."name" in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([48, 'baz', null], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->orWhereIn('contacts.name', [48, 'baz', null]); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" or "contacts"."name" in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([48, 'baz', null], $builder->getBindings()); + } + + public function testJoinWhereInSubquery() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $q = $this->getBuilder(); + $q->select('name')->from('contacts')->where('name', 'baz'); + $j->on('users.id', '=', 'contacts.id')->whereIn('contacts.name', $q); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" and "contacts"."name" in (select "name" from "contacts" where "name" = ?)', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $q = $this->getBuilder(); + $q->select('name')->from('contacts')->where('name', 'baz'); + $j->on('users.id', '=', 'contacts.id')->orWhereIn('contacts.name', $q); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" or "contacts"."name" in (select "name" from "contacts" where "name" = ?)', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testJoinWhereNotIn() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->whereNotIn('contacts.name', [48, 'baz', null]); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" and "contacts"."name" not in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([48, 'baz', null], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->orWhereNotIn('contacts.name', [48, 'baz', null]); + }); + $this->assertSame('select * from "users" inner join "contacts" on "users"."id" = "contacts"."id" or "contacts"."name" not in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([48, 'baz', null], $builder->getBindings()); + } + + public function testJoinsWithNestedConditions() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->leftJoin('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->where(function ($j) { + $j->where('contacts.country', '=', 'US')->orWhere('contacts.is_partner', '=', 1); + }); + }); + $this->assertSame('select * from "users" left join "contacts" on "users"."id" = "contacts"."id" and ("contacts"."country" = ? or "contacts"."is_partner" = ?)', $builder->toSql()); + $this->assertEquals(['US', 1], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->leftJoin('contacts', function ($j) { + $j->on('users.id', '=', 'contacts.id')->where('contacts.is_active', '=', 1)->orOn(function ($j) { + $j->orWhere(function ($j) { + $j->where('contacts.country', '=', 'UK')->orOn('contacts.type', '=', 'users.type'); + })->where(function ($j) { + $j->where('contacts.country', '=', 'US')->orWhereNull('contacts.is_partner'); + }); + }); + }); + $this->assertSame('select * from "users" left join "contacts" on "users"."id" = "contacts"."id" and "contacts"."is_active" = ? or (("contacts"."country" = ? or "contacts"."type" = "users"."type") and ("contacts"."country" = ? or "contacts"."is_partner" is null))', $builder->toSql()); + $this->assertEquals([1, 'UK', 'US'], $builder->getBindings()); + } + + public function testJoinsWithAdvancedConditions() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->leftJoin('contacts', function ($j) { + $j->on('users.id', 'contacts.id')->where(function ($j) { + $j->whereRole('admin') + ->orWhereNull('contacts.disabled') + ->orWhereRaw('year(contacts.created_at) = 2016'); + }); + }); + $this->assertSame('select * from "users" left join "contacts" on "users"."id" = "contacts"."id" and ("role" = ? or "contacts"."disabled" is null or year(contacts.created_at) = 2016)', $builder->toSql()); + $this->assertEquals(['admin'], $builder->getBindings()); + } + + public function testJoinsWithSubqueryCondition() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->leftJoin('contacts', function ($j) { + $j->on('users.id', 'contacts.id')->whereIn('contact_type_id', function ($q) { + $q->select('id')->from('contact_types') + ->where('category_id', '1') + ->whereNull('deleted_at'); + }); + }); + $this->assertSame('select * from "users" left join "contacts" on "users"."id" = "contacts"."id" and "contact_type_id" in (select "id" from "contact_types" where "category_id" = ? and "deleted_at" is null)', $builder->toSql()); + $this->assertEquals(['1'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->leftJoin('contacts', function ($j) { + $j->on('users.id', 'contacts.id')->whereExists(function ($q) { + $q->selectRaw('1')->from('contact_types') + ->whereRaw('contact_types.id = contacts.contact_type_id') + ->where('category_id', '1') + ->whereNull('deleted_at'); + }); + }); + $this->assertSame('select * from "users" left join "contacts" on "users"."id" = "contacts"."id" and exists (select 1 from "contact_types" where contact_types.id = contacts.contact_type_id and "category_id" = ? and "deleted_at" is null)', $builder->toSql()); + $this->assertEquals(['1'], $builder->getBindings()); + } + + public function testJoinsWithAdvancedSubqueryCondition() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->leftJoin('contacts', function ($j) { + $j->on('users.id', 'contacts.id')->whereExists(function ($q) { + $q->selectRaw('1')->from('contact_types') + ->whereRaw('contact_types.id = contacts.contact_type_id') + ->where('category_id', '1') + ->whereNull('deleted_at') + ->whereIn('level_id', function ($q) { + $q->select('id')->from('levels') + ->where('is_active', true); + }); + }); + }); + $this->assertSame('select * from "users" left join "contacts" on "users"."id" = "contacts"."id" and exists (select 1 from "contact_types" where contact_types.id = contacts.contact_type_id and "category_id" = ? and "deleted_at" is null and "level_id" in (select "id" from "levels" where "is_active" = ?))', $builder->toSql()); + $this->assertEquals(['1', true], $builder->getBindings()); + } + + public function testJoinsWithNestedJoins() + { + $builder = $this->getBuilder(); + $builder->select('users.id', 'contacts.id', 'contact_types.id')->from('users')->leftJoin('contacts', function ($j) { + $j->on('users.id', 'contacts.id')->join('contact_types', 'contacts.contact_type_id', '=', 'contact_types.id'); + }); + $this->assertSame('select "users"."id", "contacts"."id", "contact_types"."id" from "users" left join ("contacts" inner join "contact_types" on "contacts"."contact_type_id" = "contact_types"."id") on "users"."id" = "contacts"."id"', $builder->toSql()); + } + + public function testJoinsWithMultipleNestedJoins() + { + $builder = $this->getBuilder(); + $builder->select('users.id', 'contacts.id', 'contact_types.id', 'countries.id', 'planets.id')->from('users')->leftJoin('contacts', function ($j) { + $j->on('users.id', 'contacts.id') + ->join('contact_types', 'contacts.contact_type_id', '=', 'contact_types.id') + ->leftJoin('countries', function ($q) { + $q->on('contacts.country', '=', 'countries.country') + ->join('planets', function ($q) { + $q->on('countries.planet_id', '=', 'planet.id') + ->where('planet.is_settled', '=', 1) + ->where('planet.population', '>=', 10000); + }); + }); + }); + $this->assertSame('select "users"."id", "contacts"."id", "contact_types"."id", "countries"."id", "planets"."id" from "users" left join ("contacts" inner join "contact_types" on "contacts"."contact_type_id" = "contact_types"."id" left join ("countries" inner join "planets" on "countries"."planet_id" = "planet"."id" and "planet"."is_settled" = ? and "planet"."population" >= ?) on "contacts"."country" = "countries"."country") on "users"."id" = "contacts"."id"', $builder->toSql()); + $this->assertEquals(['1', 10000], $builder->getBindings()); + } + + public function testJoinsWithNestedJoinWithAdvancedSubqueryCondition() + { + $builder = $this->getBuilder(); + $builder->select('users.id', 'contacts.id', 'contact_types.id')->from('users')->leftJoin('contacts', function ($j) { + $j->on('users.id', 'contacts.id') + ->join('contact_types', 'contacts.contact_type_id', '=', 'contact_types.id') + ->whereExists(function ($q) { + $q->select('*')->from('countries') + ->whereColumn('contacts.country', '=', 'countries.country') + ->join('planets', function ($q) { + $q->on('countries.planet_id', '=', 'planet.id') + ->where('planet.is_settled', '=', 1); + }) + ->where('planet.population', '>=', 10000); + }); + }); + $this->assertSame('select "users"."id", "contacts"."id", "contact_types"."id" from "users" left join ("contacts" inner join "contact_types" on "contacts"."contact_type_id" = "contact_types"."id") on "users"."id" = "contacts"."id" and exists (select * from "countries" inner join "planets" on "countries"."planet_id" = "planet"."id" and "planet"."is_settled" = ? where "contacts"."country" = "countries"."country" and "planet"."population" >= ?)', $builder->toSql()); + $this->assertEquals(['1', 10000], $builder->getBindings()); + } + + public function testJoinWithNestedOnCondition() + { + $builder = $this->getBuilder(); + $builder->select('users.id')->from('users')->join('contacts', function (JoinClause $j) { + return $j + ->on('users.id', 'contacts.id') + ->addNestedWhereQuery($this->getBuilder()->where('contacts.id', 1)); + }); + $this->assertSame('select "users"."id" from "users" inner join "contacts" on "users"."id" = "contacts"."id" and ("contacts"."id" = ?)', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testJoinSub() + { + $builder = $this->getBuilder(); + $builder->from('users')->joinSub('select * from "contacts"', 'sub', 'users.id', '=', 'sub.id'); + $this->assertSame('select * from "users" inner join (select * from "contacts") as "sub" on "users"."id" = "sub"."id"', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->from('users')->joinSub(function ($q) { + $q->from('contacts'); + }, 'sub', 'users.id', '=', 'sub.id'); + $this->assertSame('select * from "users" inner join (select * from "contacts") as "sub" on "users"."id" = "sub"."id"', $builder->toSql()); + + $builder = $this->getBuilder(); + $eloquentBuilder = new EloquentBuilder($this->getBuilder()->from('contacts')); + $builder->from('users')->joinSub($eloquentBuilder, 'sub', 'users.id', '=', 'sub.id'); + $this->assertSame('select * from "users" inner join (select * from "contacts") as "sub" on "users"."id" = "sub"."id"', $builder->toSql()); + + $builder = $this->getBuilder(); + $sub1 = $this->getBuilder()->from('contacts')->where('name', 'foo'); + $sub2 = $this->getBuilder()->from('contacts')->where('name', 'bar'); + $builder->from('users') + ->joinSub($sub1, 'sub1', 'users.id', '=', 1, 'inner', true) + ->joinSub($sub2, 'sub2', 'users.id', '=', 'sub2.user_id'); + $expected = 'select * from "users" '; + $expected .= 'inner join (select * from "contacts" where "name" = ?) as "sub1" on "users"."id" = ? '; + $expected .= 'inner join (select * from "contacts" where "name" = ?) as "sub2" on "users"."id" = "sub2"."user_id"'; + $this->assertEquals($expected, $builder->toSql()); + $this->assertEquals(['foo', 1, 'bar'], $builder->getRawBindings()['join']); + + $this->expectException(InvalidArgumentException::class); + $builder = $this->getBuilder(); + $builder->from('users')->joinSub(['foo'], 'sub', 'users.id', '=', 'sub.id'); + } + + public function testJoinSubWithPrefix() + { + $builder = $this->getBuilder(prefix: 'prefix_'); + $builder->from('users')->joinSub('select * from "contacts"', 'sub', 'users.id', '=', 'sub.id'); + $this->assertSame('select * from "prefix_users" inner join (select * from "contacts") as "prefix_sub" on "prefix_users"."id" = "prefix_sub"."id"', $builder->toSql()); + } + + public function testLeftJoinSub() + { + $builder = $this->getBuilder(); + $builder->from('users')->leftJoinSub($this->getBuilder()->from('contacts'), 'sub', 'users.id', '=', 'sub.id'); + $this->assertSame('select * from "users" left join (select * from "contacts") as "sub" on "users"."id" = "sub"."id"', $builder->toSql()); + + $this->expectException(InvalidArgumentException::class); + $builder = $this->getBuilder(); + $builder->from('users')->leftJoinSub(['foo'], 'sub', 'users.id', '=', 'sub.id'); + } + + public function testRightJoinSub() + { + $builder = $this->getBuilder(); + $builder->from('users')->rightJoinSub($this->getBuilder()->from('contacts'), 'sub', 'users.id', '=', 'sub.id'); + $this->assertSame('select * from "users" right join (select * from "contacts") as "sub" on "users"."id" = "sub"."id"', $builder->toSql()); + + $this->expectException(InvalidArgumentException::class); + $builder = $this->getBuilder(); + $builder->from('users')->rightJoinSub(['foo'], 'sub', 'users.id', '=', 'sub.id'); + } + + public function testJoinLateral() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->from('users')->joinLateral('select * from `contacts` where `contracts`.`user_id` = `users`.`id`', 'sub'); + $this->assertSame('select * from `users` inner join lateral (select * from `contacts` where `contracts`.`user_id` = `users`.`id`) as `sub` on true', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->from('users')->joinLateral(function ($q) { + $q->from('contacts')->whereColumn('contracts.user_id', 'users.id'); + }, 'sub'); + $this->assertSame('select * from `users` inner join lateral (select * from `contacts` where `contracts`.`user_id` = `users`.`id`) as `sub` on true', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $sub = $this->getMySqlBuilder(); + $sub->getConnection()->shouldReceive('getDatabaseName'); + $eloquentBuilder = new EloquentBuilder($sub->from('contacts')->whereColumn('contracts.user_id', 'users.id')); + $builder->from('users')->joinLateral($eloquentBuilder, 'sub'); + $this->assertSame('select * from `users` inner join lateral (select * from `contacts` where `contracts`.`user_id` = `users`.`id`) as `sub` on true', $builder->toSql()); + + $sub1 = $this->getMySqlBuilder(); + $sub1->getConnection()->shouldReceive('getDatabaseName'); + $sub1 = $sub1->from('contacts')->whereColumn('contracts.user_id', 'users.id')->where('name', 'foo'); + + $sub2 = $this->getMySqlBuilder(); + $sub2->getConnection()->shouldReceive('getDatabaseName'); + $sub2 = $sub2->from('contacts')->whereColumn('contracts.user_id', 'users.id')->where('name', 'bar'); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->from('users')->joinLateral($sub1, 'sub1')->joinLateral($sub2, 'sub2'); + + $expected = 'select * from `users` '; + $expected .= 'inner join lateral (select * from `contacts` where `contracts`.`user_id` = `users`.`id` and `name` = ?) as `sub1` on true '; + $expected .= 'inner join lateral (select * from `contacts` where `contracts`.`user_id` = `users`.`id` and `name` = ?) as `sub2` on true'; + + $this->assertEquals($expected, $builder->toSql()); + $this->assertEquals(['foo', 'bar'], $builder->getRawBindings()['join']); + + $this->expectException(InvalidArgumentException::class); + $builder = $this->getMySqlBuilder(); + $builder->from('users')->joinLateral(['foo'], 'sub'); + } + + public function testJoinLateralMariaDb() + { + $this->expectException(RuntimeException::class); + $builder = $this->getMariaDbBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->from('users')->joinLateral(function ($q) { + $q->from('contacts')->whereColumn('contracts.user_id', 'users.id'); + }, 'sub')->toSql(); + } + + public function testJoinLateralSQLite() + { + $this->expectException(RuntimeException::class); + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->from('users')->joinLateral(function ($q) { + $q->from('contacts')->whereColumn('contracts.user_id', 'users.id'); + }, 'sub')->toSql(); + } + + public function testJoinLateralPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->from('users')->joinLateral(function ($q) { + $q->from('contacts')->whereColumn('contracts.user_id', 'users.id'); + }, 'sub'); + $this->assertSame('select * from "users" inner join lateral (select * from "contacts" where "contracts"."user_id" = "users"."id") as "sub" on true', $builder->toSql()); + } + + public function testJoinLateralSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->from('users')->joinLateral(function ($q) { + $q->from('contacts')->whereColumn('contracts.user_id', 'users.id'); + }, 'sub'); + $this->assertSame('select * from [users] cross apply (select * from [contacts] where [contracts].[user_id] = [users].[id]) as [sub]', $builder->toSql()); + } + + public function testJoinLateralWithPrefix() + { + $builder = $this->getMySqlBuilder(prefix: 'prefix_'); + $builder->from('users')->joinLateral('select * from `contacts` where `contracts`.`user_id` = `users`.`id`', 'sub'); + $this->assertSame('select * from `prefix_users` inner join lateral (select * from `contacts` where `contracts`.`user_id` = `users`.`id`) as `prefix_sub` on true', $builder->toSql()); + } + + public function testLeftJoinLateral() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + + $sub = $this->getMySqlBuilder(); + $sub->getConnection()->shouldReceive('getDatabaseName'); + + $builder->from('users')->leftJoinLateral($sub->from('contacts')->whereColumn('contracts.user_id', 'users.id'), 'sub'); + $this->assertSame('select * from `users` left join lateral (select * from `contacts` where `contracts`.`user_id` = `users`.`id`) as `sub` on true', $builder->toSql()); + + $this->expectException(InvalidArgumentException::class); + $builder = $this->getBuilder(); + $builder->from('users')->leftJoinLateral(['foo'], 'sub'); + } + + public function testLeftJoinLateralSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->from('users')->leftJoinLateral(function ($q) { + $q->from('contacts')->whereColumn('contracts.user_id', 'users.id'); + }, 'sub'); + $this->assertSame('select * from [users] outer apply (select * from [contacts] where [contracts].[user_id] = [users].[id]) as [sub]', $builder->toSql()); + } + + public function testRawExpressionsInSelect() + { + $builder = $this->getBuilder(); + $builder->select(new Raw('substr(foo, 6)'))->from('users'); + $this->assertSame('select substr(foo, 6) from "users"', $builder->toSql()); + } + + public function testFindReturnsFirstResultByID() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select * from "users" where "id" = ? limit 1', [1], true)->andReturn([['foo' => 'bar']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['foo' => 'bar']])->andReturnUsing(function ($query, $results) { + return $results; + }); + $results = $builder->from('users')->find(1); + $this->assertEquals(['foo' => 'bar'], $results); + } + + public function testFindOrReturnsFirstResultByID() + { + $builder = $this->getMockQueryBuilder(); + $data = m::mock(stdClass::class); + $builder->shouldReceive('first')->andReturn($data)->once(); + $builder->shouldReceive('first')->with(['column'])->andReturn($data)->once(); + $builder->shouldReceive('first')->andReturn(null)->once(); + + $this->assertSame($data, $builder->findOr(1, fn () => 'callback result')); + $this->assertSame($data, $builder->findOr(1, ['column'], fn () => 'callback result')); + $this->assertSame('callback result', $builder->findOr(1, fn () => 'callback result')); + } + + public function testFirstMethodReturnsFirstResult() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select * from "users" where "id" = ? limit 1', [1], true)->andReturn([['foo' => 'bar']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['foo' => 'bar']])->andReturnUsing(function ($query, $results) { + return $results; + }); + $results = $builder->from('users')->where('id', '=', 1)->first(); + $this->assertEquals(['foo' => 'bar'], $results); + } + + public function testFirstOrFailMethodReturnsFirstResult() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select * from "users" where "id" = ? limit 1', [1], true)->andReturn([['foo' => 'bar']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['foo' => 'bar']])->andReturnUsing(function ($query, $results) { + return $results; + }); + $results = $builder->from('users')->where('id', '=', 1)->firstOrFail(); + $this->assertEquals(['foo' => 'bar'], $results); + } + + public function testFirstOrFailMethodThrowsRecordNotFoundException() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select * from "users" where "id" = ? limit 1', [1], true)->andReturn([]); + + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [])->andReturn([]); + + $this->expectException(RecordNotFoundException::class); + $this->expectExceptionMessage('No record found for the given query.'); + + $builder->from('users')->where('id', '=', 1)->firstOrFail(); + } + + public function testPluckMethodGetsCollectionOfColumnValues() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->andReturn([['foo' => 'bar'], ['foo' => 'baz']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['foo' => 'bar'], ['foo' => 'baz']])->andReturnUsing(function ($query, $results) { + return $results; + }); + $results = $builder->from('users')->where('id', '=', 1)->pluck('foo'); + $this->assertEquals(['bar', 'baz'], $results->all()); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->andReturn([['id' => 1, 'foo' => 'bar'], ['id' => 10, 'foo' => 'baz']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['id' => 1, 'foo' => 'bar'], ['id' => 10, 'foo' => 'baz']])->andReturnUsing(function ($query, $results) { + return $results; + }); + $results = $builder->from('users')->where('id', '=', 1)->pluck('foo', 'id'); + $this->assertEquals([1 => 'bar', 10 => 'baz'], $results->all()); + } + + public function testPluckAvoidsDuplicateColumnSelection() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select "foo" from "users" where "id" = ?', [1], true)->andReturn([['foo' => 'bar']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['foo' => 'bar']])->andReturnUsing(function ($query, $results) { + return $results; + }); + $results = $builder->from('users')->where('id', '=', 1)->pluck('foo', 'foo'); + $this->assertEquals(['bar' => 'bar'], $results->all()); + } + + public function testImplode() + { + // Test without glue. + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->andReturn([['foo' => 'bar'], ['foo' => 'baz']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['foo' => 'bar'], ['foo' => 'baz']])->andReturnUsing(function ($query, $results) { + return $results; + }); + $results = $builder->from('users')->where('id', '=', 1)->implode('foo'); + $this->assertSame('barbaz', $results); + + // Test with glue. + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->andReturn([['foo' => 'bar'], ['foo' => 'baz']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['foo' => 'bar'], ['foo' => 'baz']])->andReturnUsing(function ($query, $results) { + return $results; + }); + $results = $builder->from('users')->where('id', '=', 1)->implode('foo', ','); + $this->assertSame('bar,baz', $results); + } + + public function testValueMethodReturnsSingleColumn() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select "foo" from "users" where "id" = ? limit 1', [1], true)->andReturn([['foo' => 'bar']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['foo' => 'bar']])->andReturn([['foo' => 'bar']]); + $results = $builder->from('users')->where('id', '=', 1)->value('foo'); + $this->assertSame('bar', $results); + } + + public function testRawValueMethodReturnsSingleColumn() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select UPPER("foo") from "users" where "id" = ? limit 1', [1], true)->andReturn([['UPPER("foo")' => 'BAR']]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->with($builder, [['UPPER("foo")' => 'BAR']])->andReturn([['UPPER("foo")' => 'BAR']]); + $results = $builder->from('users')->where('id', '=', 1)->rawValue('UPPER("foo")'); + $this->assertSame('BAR', $results); + } + + public function testAggregateFunctions() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + $results = $builder->from('users')->count(); + $this->assertEquals(1, $results); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true)->andReturn([['exists' => 1]]); + $results = $builder->from('users')->exists(); + $this->assertTrue($results); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true)->andReturn([['exists' => 0]]); + $results = $builder->from('users')->doesntExist(); + $this->assertTrue($results); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select max("id") as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + $results = $builder->from('users')->max('id'); + $this->assertEquals(1, $results); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select min("id") as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + $results = $builder->from('users')->min('id'); + $this->assertEquals(1, $results); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select sum("id") as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + $results = $builder->from('users')->sum('id'); + $this->assertEquals(1, $results); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select avg("id") as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + $results = $builder->from('users')->avg('id'); + $this->assertEquals(1, $results); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select avg("id") as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + $results = $builder->from('users')->average('id'); + $this->assertEquals(1, $results); + } + + public function testSqlServerExists() + { + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select top 1 1 [exists] from [users]', [], true)->andReturn([['exists' => 1]]); + $results = $builder->from('users')->exists(); + $this->assertTrue($results); + } + + public function testExistsOr() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->andReturn([['exists' => 1]]); + $results = $builder->from('users')->doesntExistOr(function () { + return 123; + }); + $this->assertSame(123, $results); + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->andReturn([['exists' => 0]]); + $results = $builder->from('users')->doesntExistOr(function () { + throw new RuntimeException; + }); + $this->assertTrue($results); + } + + public function testDoesntExistsOr() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->andReturn([['exists' => 0]]); + $results = $builder->from('users')->existsOr(function () { + return 123; + }); + $this->assertSame(123, $results); + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->andReturn([['exists' => 1]]); + $results = $builder->from('users')->existsOr(function () { + throw new RuntimeException; + }); + $this->assertTrue($results); + } + + public function testAggregateResetFollowedByGet() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select sum("id") as aggregate from "users"', [], true)->andReturn([['aggregate' => 2]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select "column1", "column2" from "users"', [], true)->andReturn([['column1' => 'foo', 'column2' => 'bar']]); + $builder->getProcessor()->shouldReceive('processSelect')->andReturnUsing(function ($builder, $results) { + return $results; + }); + $builder->from('users')->select('column1', 'column2'); + $count = $builder->count(); + $this->assertEquals(1, $count); + $sum = $builder->sum('id'); + $this->assertEquals(2, $sum); + $result = $builder->get(); + $this->assertEquals([['column1' => 'foo', 'column2' => 'bar']], $result->all()); + } + + public function testAggregateResetFollowedBySelectGet() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select count("column1") as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select "column2", "column3" from "users"', [], true)->andReturn([['column2' => 'foo', 'column3' => 'bar']]); + $builder->getProcessor()->shouldReceive('processSelect')->andReturnUsing(function ($builder, $results) { + return $results; + }); + $builder->from('users'); + $count = $builder->count('column1'); + $this->assertEquals(1, $count); + $result = $builder->select('column2', 'column3')->get(); + $this->assertEquals([['column2' => 'foo', 'column3' => 'bar']], $result->all()); + } + + public function testAggregateResetFollowedByGetWithColumns() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select count("column1") as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getConnection()->shouldReceive('select')->once()->with('select "column2", "column3" from "users"', [], true)->andReturn([['column2' => 'foo', 'column3' => 'bar']]); + $builder->getProcessor()->shouldReceive('processSelect')->andReturnUsing(function ($builder, $results) { + return $results; + }); + $builder->from('users'); + $count = $builder->count('column1'); + $this->assertEquals(1, $count); + $result = $builder->get(['column2', 'column3']); + $this->assertEquals([['column2' => 'foo', 'column3' => 'bar']], $result->all()); + } + + public function testAggregateWithSubSelect() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select count(*) as aggregate from "users"', [], true)->andReturn([['aggregate' => 1]]); + $builder->getProcessor()->shouldReceive('processSelect')->once()->andReturnUsing(function ($builder, $results) { + return $results; + }); + $builder->from('users')->selectSub(function ($query) { + $query->from('posts')->select('foo', 'bar')->where('title', 'foo'); + }, 'post'); + $count = $builder->count(); + $this->assertEquals(1, $count); + $this->assertSame('(select "foo", "bar" from "posts" where "title" = ?) as "post"', $builder->getGrammar()->getValue($builder->columns[0])); + $this->assertEquals(['foo'], $builder->getBindings()); + } + + public function testSubqueriesBindings() + { + $builder = $this->getBuilder(); + $second = $this->getBuilder()->select('*')->from('users')->orderByRaw('id = ?', 2); + $third = $this->getBuilder()->select('*')->from('users')->where('id', 3)->groupBy('id')->having('id', '!=', 4); + $builder->groupBy('a')->having('a', '=', 1)->union($second)->union($third); + $this->assertEquals([0 => 1, 1 => 2, 2 => 3, 3 => 4], $builder->getBindings()); + + $builder = $this->getBuilder()->select('*')->from('users')->where('email', '=', function ($q) { + $q->select(new Raw('max(id)')) + ->from('users')->where('email', '=', 'bar') + ->orderByRaw('email like ?', '%.com') + ->groupBy('id')->having('id', '=', 4); + })->orWhere('id', '=', 'foo')->groupBy('id')->having('id', '=', 5); + $this->assertEquals([0 => 'bar', 1 => 4, 2 => '%.com', 3 => 'foo', 4 => 5], $builder->getBindings()); + } + + public function testInsertMethod() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('insert')->once()->with('insert into "users" ("email") values (?)', ['foo'])->andReturn(true); + $result = $builder->from('users')->insert(['email' => 'foo']); + $this->assertTrue($result); + } + + public function testInsertUsingMethod() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "table1" ("foo") select "bar" from "table2" where "foreign_id" = ?', [5])->andReturn(1); + + $result = $builder->from('table1')->insertUsing( + ['foo'], + function (Builder $query) { + $query->select(['bar'])->from('table2')->where('foreign_id', '=', 5); + } + ); + + $this->assertEquals(1, $result); + } + + public function testInsertUsingWithEmptyColumns() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "table1" select * from "table2" where "foreign_id" = ?', [5])->andReturn(1); + + $result = $builder->from('table1')->insertUsing( + [], + function (Builder $query) { + $query->from('table2')->where('foreign_id', '=', 5); + } + ); + + $this->assertEquals(1, $result); + } + + public function testInsertUsingInvalidSubquery() + { + $this->expectException(InvalidArgumentException::class); + $builder = $this->getBuilder(); + $builder->from('table1')->insertUsing(['foo'], ['bar']); + } + + public function testInsertOrIgnoreMethod() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support'); + $builder = $this->getBuilder(); + $builder->from('users')->insertOrIgnore(['email' => 'foo']); + } + + public function testMySqlInsertOrIgnoreMethod() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert ignore into `users` (`email`) values (?)', ['foo'])->andReturn(1); + $result = $builder->from('users')->insertOrIgnore(['email' => 'foo']); + $this->assertEquals(1, $result); + } + + public function testPostgresInsertOrIgnoreMethod() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email") values (?) on conflict do nothing', ['foo'])->andReturn(1); + $result = $builder->from('users')->insertOrIgnore(['email' => 'foo']); + $this->assertEquals(1, $result); + } + + public function testSQLiteInsertOrIgnoreMethod() + { + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert or ignore into "users" ("email") values (?)', ['foo'])->andReturn(1); + $result = $builder->from('users')->insertOrIgnore(['email' => 'foo']); + $this->assertEquals(1, $result); + } + + public function testSqlServerInsertOrIgnoreMethod() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support'); + $builder = $this->getSqlServerBuilder(); + $builder->from('users')->insertOrIgnore(['email' => 'foo']); + } + + public function testInsertOrIgnoreUsingMethod() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support'); + $builder = $this->getBuilder(); + $builder->from('users')->insertOrIgnoreUsing(['email' => 'foo'], 'bar'); + } + + public function testSqlServerInsertOrIgnoreUsingMethod() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support'); + $builder = $this->getSqlServerBuilder(); + $builder->from('users')->insertOrIgnoreUsing(['email' => 'foo'], 'bar'); + } + + public function testMySqlInsertOrIgnoreUsingMethod() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert ignore into `table1` (`foo`) select `bar` from `table2` where `foreign_id` = ?', [0 => 5])->andReturn(1); + + $result = $builder->from('table1')->insertOrIgnoreUsing( + ['foo'], + function (Builder $query) { + $query->select(['bar'])->from('table2')->where('foreign_id', '=', 5); + } + ); + + $this->assertEquals(1, $result); + } + + public function testMySqlInsertOrIgnoreUsingWithEmptyColumns() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert ignore into `table1` select * from `table2` where `foreign_id` = ?', [0 => 5])->andReturn(1); + + $result = $builder->from('table1')->insertOrIgnoreUsing( + [], + function (Builder $query) { + $query->from('table2')->where('foreign_id', '=', 5); + } + ); + + $this->assertEquals(1, $result); + } + + public function testMySqlInsertOrIgnoreUsingInvalidSubquery() + { + $this->expectException(InvalidArgumentException::class); + $builder = $this->getMySqlBuilder(); + $builder->from('table1')->insertOrIgnoreUsing(['foo'], ['bar']); + } + + public function testPostgresInsertOrIgnoreUsingMethod() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "table1" ("foo") select "bar" from "table2" where "foreign_id" = ? on conflict do nothing', [5])->andReturn(1); + + $result = $builder->from('table1')->insertOrIgnoreUsing( + ['foo'], + function (Builder $query) { + $query->select(['bar'])->from('table2')->where('foreign_id', '=', 5); + } + ); + + $this->assertEquals(1, $result); + } + + public function testPostgresInsertOrIgnoreUsingWithEmptyColumns() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "table1" select * from "table2" where "foreign_id" = ? on conflict do nothing', [5])->andReturn(1); + + $result = $builder->from('table1')->insertOrIgnoreUsing( + [], + function (Builder $query) { + $query->from('table2')->where('foreign_id', '=', 5); + } + ); + + $this->assertEquals(1, $result); + } + + public function testPostgresInsertOrIgnoreUsingInvalidSubquery() + { + $this->expectException(InvalidArgumentException::class); + $builder = $this->getPostgresBuilder(); + $builder->from('table1')->insertOrIgnoreUsing(['foo'], ['bar']); + } + + public function testSQLiteInsertOrIgnoreUsingMethod() + { + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert or ignore into "table1" ("foo") select "bar" from "table2" where "foreign_id" = ?', [5])->andReturn(1); + + $result = $builder->from('table1')->insertOrIgnoreUsing( + ['foo'], + function (Builder $query) { + $query->select(['bar'])->from('table2')->where('foreign_id', '=', 5); + } + ); + + $this->assertEquals(1, $result); + } + + public function testSQLiteInsertOrIgnoreUsingWithEmptyColumns() + { + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('getDatabaseName'); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert or ignore into "table1" select * from "table2" where "foreign_id" = ?', [5])->andReturn(1); + + $result = $builder->from('table1')->insertOrIgnoreUsing( + [], + function (Builder $query) { + $query->from('table2')->where('foreign_id', '=', 5); + } + ); + + $this->assertEquals(1, $result); + } + + public function testSQLiteInsertOrIgnoreUsingInvalidSubquery() + { + $this->expectException(InvalidArgumentException::class); + $builder = $this->getSQLiteBuilder(); + $builder->from('table1')->insertOrIgnoreUsing(['foo'], ['bar']); + } + + public function testInsertGetIdMethod() + { + $builder = $this->getBuilder(); + $builder->getProcessor()->shouldReceive('processInsertGetId')->once()->with($builder, 'insert into "users" ("email") values (?)', ['foo'], 'id')->andReturn(1); + $result = $builder->from('users')->insertGetId(['email' => 'foo'], 'id'); + $this->assertEquals(1, $result); + } + + public function testInsertGetIdMethodRemovesExpressions() + { + $builder = $this->getBuilder(); + $builder->getProcessor()->shouldReceive('processInsertGetId')->once()->with($builder, 'insert into "users" ("email", "bar") values (?, bar)', ['foo'], 'id')->andReturn(1); + $result = $builder->from('users')->insertGetId(['email' => 'foo', 'bar' => new Raw('bar')], 'id'); + $this->assertEquals(1, $result); + } + + public function testInsertGetIdWithEmptyValues() + { + $builder = $this->getMySqlBuilder(); + $builder->getProcessor()->shouldReceive('processInsertGetId')->once()->with($builder, 'insert into `users` () values ()', [], null); + $builder->from('users')->insertGetId([]); + + $builder = $this->getPostgresBuilder(); + $builder->getProcessor()->shouldReceive('processInsertGetId')->once()->with($builder, 'insert into "users" default values returning "id"', [], null); + $builder->from('users')->insertGetId([]); + + $builder = $this->getSQLiteBuilder(); + $builder->getProcessor()->shouldReceive('processInsertGetId')->once()->with($builder, 'insert into "users" default values', [], null); + $builder->from('users')->insertGetId([]); + + $builder = $this->getSqlServerBuilder(); + $builder->getProcessor()->shouldReceive('processInsertGetId')->once()->with($builder, 'insert into [users] default values', [], null); + $builder->from('users')->insertGetId([]); + } + + public function testInsertMethodRespectsRawBindings() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('insert')->once()->with('insert into "users" ("email") values (CURRENT TIMESTAMP)', [])->andReturn(true); + $result = $builder->from('users')->insert(['email' => new Raw('CURRENT TIMESTAMP')]); + $this->assertTrue($result); + } + + public function testMultipleInsertsWithExpressionValues() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('insert')->once()->with('insert into "users" ("email") values (UPPER(\'Foo\')), (LOWER(\'Foo\'))', [])->andReturn(true); + $result = $builder->from('users')->insert([['email' => new Raw("UPPER('Foo')")], ['email' => new Raw("LOWER('Foo')")]]); + $this->assertTrue($result); + } + + public function testUpdateMethod() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? where "id" = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->where('id', '=', 1)->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update `users` set `email` = ?, `name` = ? where `id` = ? order by `foo` desc limit 5', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->where('id', '=', 1)->orderBy('foo', 'desc')->limit(5)->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpsertMethod() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection() + ->shouldReceive('getConfig')->with('use_upsert_alias')->andReturn(false) + ->shouldReceive('affectingStatement')->once()->with('insert into `users` (`email`, `name`) values (?, ?), (?, ?) on duplicate key update `email` = values(`email`), `name` = values(`name`)', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email'); + $this->assertEquals(2, $result); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection() + ->shouldReceive('getConfig')->with('use_upsert_alias')->andReturn(true) + ->shouldReceive('affectingStatement')->once()->with('insert into `users` (`email`, `name`) values (?, ?), (?, ?) as laravel_upsert_alias on duplicate key update `email` = `laravel_upsert_alias`.`email`, `name` = `laravel_upsert_alias`.`name`', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email'); + $this->assertEquals(2, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email", "name") values (?, ?), (?, ?) on conflict ("email") do update set "email" = "excluded"."email", "name" = "excluded"."name"', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email'); + $this->assertEquals(2, $result); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email", "name") values (?, ?), (?, ?) on conflict ("email") do update set "email" = "excluded"."email", "name" = "excluded"."name"', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email'); + $this->assertEquals(2, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('merge [users] using (values (?, ?), (?, ?)) [laravel_source] ([email], [name]) on [laravel_source].[email] = [users].[email] when matched then update set [email] = [laravel_source].[email], [name] = [laravel_source].[name] when not matched then insert ([email], [name]) values ([email], [name]);', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email'); + $this->assertEquals(2, $result); + } + + public function testUpsertMethodWithUpdateColumns() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection() + ->shouldReceive('getConfig')->with('use_upsert_alias')->andReturn(false) + ->shouldReceive('affectingStatement')->once()->with('insert into `users` (`email`, `name`) values (?, ?), (?, ?) on duplicate key update `name` = values(`name`)', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email', ['name']); + $this->assertEquals(2, $result); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection() + ->shouldReceive('getConfig')->with('use_upsert_alias')->andReturn(true) + ->shouldReceive('affectingStatement')->once()->with('insert into `users` (`email`, `name`) values (?, ?), (?, ?) as laravel_upsert_alias on duplicate key update `name` = `laravel_upsert_alias`.`name`', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email', ['name']); + $this->assertEquals(2, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email", "name") values (?, ?), (?, ?) on conflict ("email") do update set "name" = "excluded"."name"', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email', ['name']); + $this->assertEquals(2, $result); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email", "name") values (?, ?), (?, ?) on conflict ("email") do update set "name" = "excluded"."name"', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email', ['name']); + $this->assertEquals(2, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('merge [users] using (values (?, ?), (?, ?)) [laravel_source] ([email], [name]) on [laravel_source].[email] = [users].[email] when matched then update set [name] = [laravel_source].[name] when not matched then insert ([email], [name]) values ([email], [name]);', ['foo', 'bar', 'foo2', 'bar2'])->andReturn(2); + $result = $builder->from('users')->upsert([['email' => 'foo', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], 'email', ['name']); + $this->assertEquals(2, $result); + } + + public function testUpdateMethodWithJoins() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" inner join "orders" on "users"."id" = "orders"."user_id" set "email" = ?, "name" = ? where "users"."id" = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', 'users.id', '=', 'orders.user_id')->where('users.id', '=', 1)->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" inner join "orders" on "users"."id" = "orders"."user_id" and "users"."id" = ? set "email" = ?, "name" = ?', [1, 'foo', 'bar'])->andReturn(1); + $result = $builder->from('users')->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateMethodWithJoinsOnSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update [users] set [email] = ?, [name] = ? from [users] inner join [orders] on [users].[id] = [orders].[user_id] where [users].[id] = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', 'users.id', '=', 'orders.user_id')->where('users.id', '=', 1)->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update [users] set [email] = ?, [name] = ? from [users] inner join [orders] on [users].[id] = [orders].[user_id] and [users].[id] = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateMethodWithJoinsOnMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update `users` inner join `orders` on `users`.`id` = `orders`.`user_id` set `email` = ?, `name` = ? where `users`.`id` = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', 'users.id', '=', 'orders.user_id')->where('users.id', '=', 1)->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update `users` inner join `orders` on `users`.`id` = `orders`.`user_id` and `users`.`id` = ? set `email` = ?, `name` = ?', [1, 'foo', 'bar'])->andReturn(1); + $result = $builder->from('users')->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateMethodWithJoinsOnSQLite() + { + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? where "rowid" in (select "users"."rowid" from "users" where "users"."id" > ? order by "id" asc limit 3)', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->where('users.id', '>', 1)->limit(3)->oldest('id')->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? where "rowid" in (select "users"."rowid" from "users" inner join "orders" on "users"."id" = "orders"."user_id" where "users"."id" = ?)', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', 'users.id', '=', 'orders.user_id')->where('users.id', '=', 1)->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? where "rowid" in (select "users"."rowid" from "users" inner join "orders" on "users"."id" = "orders"."user_id" and "users"."id" = ?)', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" as "u" set "email" = ?, "name" = ? where "rowid" in (select "u"."rowid" from "users" as "u" inner join "orders" as "o" on "u"."id" = "o"."user_id")', ['foo', 'bar'])->andReturn(1); + $result = $builder->from('users as u')->join('orders as o', 'u.id', '=', 'o.user_id')->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateMethodWithJoinsAndAliasesOnSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update [u] set [email] = ?, [name] = ? from [users] as [u] inner join [orders] on [u].[id] = [orders].[user_id] where [u].[id] = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users as u')->join('orders', 'u.id', '=', 'orders.user_id')->where('u.id', '=', 1)->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateMethodWithoutJoinsOnPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? where "id" = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->where('id', '=', 1)->update(['users.email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? where "id" = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->where('id', '=', 1)->selectRaw('?', ['ignore'])->update(['users.email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users"."users" set "email" = ?, "name" = ? where "id" = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users.users')->where('id', '=', 1)->selectRaw('?', ['ignore'])->update(['users.users.email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateMethodWithJoinsOnPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? where "ctid" in (select "users"."ctid" from "users" inner join "orders" on "users"."id" = "orders"."user_id" where "users"."id" = ?)', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', 'users.id', '=', 'orders.user_id')->where('users.id', '=', 1)->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? where "ctid" in (select "users"."ctid" from "users" inner join "orders" on "users"."id" = "orders"."user_id" and "users"."id" = ?)', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? where "ctid" in (select "users"."ctid" from "users" inner join "orders" on "users"."id" = "orders"."user_id" and "users"."id" = ? where "name" = ?)', ['foo', 'bar', 1, 'baz'])->andReturn(1); + $result = $builder->from('users') + ->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->where('name', 'baz') + ->update(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateFromMethodWithJoinsOnPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? from "orders" where "users"."id" = ? and "users"."id" = "orders"."user_id"', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', 'users.id', '=', 'orders.user_id')->where('users.id', '=', 1)->updateFrom(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? from "orders" where "users"."id" = "orders"."user_id" and "users"."id" = ?', ['foo', 'bar', 1])->andReturn(1); + $result = $builder->from('users')->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->updateFrom(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ?, "name" = ? from "orders" where "name" = ? and "users"."id" = "orders"."user_id" and "users"."id" = ?', ['foo', 'bar', 'baz', 1])->andReturn(1); + $result = $builder->from('users') + ->join('orders', function ($join) { + $join->on('users.id', '=', 'orders.user_id') + ->where('users.id', '=', 1); + })->where('name', 'baz') + ->updateFrom(['email' => 'foo', 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateMethodRespectsRaw() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = foo, "name" = ? where "id" = ?', ['bar', 1])->andReturn(1); + $result = $builder->from('users')->where('id', '=', 1)->update(['email' => new Raw('foo'), 'name' => 'bar']); + $this->assertEquals(1, $result); + } + + public function testUpdateMethodWorksWithQueryAsValue() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "credits" = (select sum(credits) from "transactions" where "transactions"."user_id" = "users"."id" and "type" = ?) where "id" = ?', ['foo', 1])->andReturn(1); + $result = $builder->from('users')->where('id', '=', 1)->update(['credits' => $this->getBuilder()->from('transactions')->selectRaw('sum(credits)')->whereColumn('transactions.user_id', 'users.id')->where('type', 'foo')]); + + $this->assertEquals(1, $result); + } + + public function testUpdateOrInsertMethod() + { + $builder = m::mock(Builder::class.'[where,exists,insert]', [ + $connection = m::mock(Connection::class), + new Grammar($connection), + m::mock(Processor::class), + ]); + + $builder->shouldReceive('where')->once()->with(['email' => 'foo'])->andReturn(m::self()); + $builder->shouldReceive('exists')->once()->andReturn(false); + $builder->shouldReceive('insert')->once()->with(['email' => 'foo', 'name' => 'bar'])->andReturn(true); + + $this->assertTrue($builder->updateOrInsert(['email' => 'foo'], ['name' => 'bar'])); + + $builder = m::mock(Builder::class.'[where,exists,update]', [ + $connection = m::mock(Connection::class), + new Grammar($connection), + m::mock(Processor::class), + ]); + + $builder->shouldReceive('where')->once()->with(['email' => 'foo'])->andReturn(m::self()); + $builder->shouldReceive('exists')->once()->andReturn(true); + $builder->shouldReceive('take')->andReturnSelf(); + $builder->shouldReceive('update')->once()->with(['name' => 'bar'])->andReturn(1); + + $this->assertTrue($builder->updateOrInsert(['email' => 'foo'], ['name' => 'bar'])); + } + + public function testUpdateOrInsertMethodWorksWithEmptyUpdateValues() + { + $builder = m::spy(Builder::class.'[where,exists,update]', [ + $connection = m::mock(Connection::class), + new Grammar($connection), + m::mock(Processor::class), + ]); + + $builder->shouldReceive('where')->once()->with(['email' => 'foo'])->andReturn(m::self()); + $builder->shouldReceive('exists')->once()->andReturn(true); + + $this->assertTrue($builder->updateOrInsert(['email' => 'foo'])); + $builder->shouldNotHaveReceived('update'); + } + + public function testDeleteMethod() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" where "email" = ?', ['foo'])->andReturn(1); + $result = $builder->from('users')->where('email', '=', 'foo')->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" where "users"."id" = ?', [1])->andReturn(1); + $result = $builder->from('users')->delete(1); + $this->assertEquals(1, $result); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" where "users"."id" = ?', [1])->andReturn(1); + $result = $builder->from('users')->selectRaw('?', ['ignore'])->delete(1); + $this->assertEquals(1, $result); + + $builder = $this->getSqliteBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" where "rowid" in (select "users"."rowid" from "users" where "email" = ? order by "id" asc limit 1)', ['foo'])->andReturn(1); + $result = $builder->from('users')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from `users` where `email` = ? order by `id` asc limit 1', ['foo'])->andReturn(1); + $result = $builder->from('users')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from [users] where [email] = ?', ['foo'])->andReturn(1); + $result = $builder->from('users')->where('email', '=', 'foo')->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete top (1) from [users] where [email] = ?', ['foo'])->andReturn(1); + $result = $builder->from('users')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete(); + $this->assertEquals(1, $result); + } + + public function testDeleteWithJoinMethod() + { + $builder = $this->getSqliteBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" where "rowid" in (select "users"."rowid" from "users" inner join "contacts" on "users"."id" = "contacts"."id" where "users"."email" = ? order by "users"."id" asc limit 1)', ['foo'])->andReturn(1); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->where('users.email', '=', 'foo')->orderBy('users.id')->limit(1)->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getSqliteBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" as "u" where "rowid" in (select "u"."rowid" from "users" as "u" inner join "contacts" as "c" on "u"."id" = "c"."id")', [])->andReturn(1); + $result = $builder->from('users as u')->join('contacts as c', 'u.id', '=', 'c.id')->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete `users` from `users` inner join `contacts` on `users`.`id` = `contacts`.`id` where `email` = ?', ['foo'])->andReturn(1); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete `a` from `users` as `a` inner join `users` as `b` on `a`.`id` = `b`.`user_id` where `email` = ?', ['foo'])->andReturn(1); + $result = $builder->from('users AS a')->join('users AS b', 'a.id', '=', 'b.user_id')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete `users` from `users` inner join `contacts` on `users`.`id` = `contacts`.`id` where `users`.`id` = ?', [1])->andReturn(1); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->orderBy('id')->limit(1)->delete(1); + $this->assertEquals(1, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete [users] from [users] inner join [contacts] on [users].[id] = [contacts].[id] where [email] = ?', ['foo'])->andReturn(1); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->where('email', '=', 'foo')->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete [a] from [users] as [a] inner join [users] as [b] on [a].[id] = [b].[user_id] where [email] = ?', ['foo'])->andReturn(1); + $result = $builder->from('users AS a')->join('users AS b', 'a.id', '=', 'b.user_id')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getSqlServerBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete [users] from [users] inner join [contacts] on [users].[id] = [contacts].[id] where [users].[id] = ?', [1])->andReturn(1); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->delete(1); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" where "ctid" in (select "users"."ctid" from "users" inner join "contacts" on "users"."id" = "contacts"."id" where "users"."email" = ?)', ['foo'])->andReturn(1); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->where('users.email', '=', 'foo')->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" as "a" where "ctid" in (select "a"."ctid" from "users" as "a" inner join "users" as "b" on "a"."id" = "b"."user_id" where "email" = ? order by "id" asc limit 1)', ['foo'])->andReturn(1); + $result = $builder->from('users AS a')->join('users AS b', 'a.id', '=', 'b.user_id')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" where "ctid" in (select "users"."ctid" from "users" inner join "contacts" on "users"."id" = "contacts"."id" where "users"."id" = ? order by "id" asc limit 1)', [1])->andReturn(1); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->orderBy('id')->limit(1)->delete(1); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" where "ctid" in (select "users"."ctid" from "users" inner join "contacts" on "users"."id" = "contacts"."user_id" and "users"."id" = ? where "name" = ?)', [1, 'baz'])->andReturn(1); + $result = $builder->from('users') + ->join('contacts', function ($join) { + $join->on('users.id', '=', 'contacts.user_id') + ->where('users.id', '=', 1); + })->where('name', 'baz') + ->delete(); + $this->assertEquals(1, $result); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users" where "ctid" in (select "users"."ctid" from "users" inner join "contacts" on "users"."id" = "contacts"."id")', [])->andReturn(1); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->delete(); + $this->assertEquals(1, $result); + } + + public function testTruncateMethod() + { + $builder = $this->getBuilder(); + $connection = $builder->getConnection(); + $connection->shouldReceive('statement')->once()->with('truncate table "users"', []); + $builder->from('users')->truncate(); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('getSchemaBuilder->parseSchemaAndTable')->andReturn([null, 'users']); + $builder->from('users'); + $this->assertEquals([ + 'delete from sqlite_sequence where name = ?' => ['users'], + 'delete from "users"' => [], + ], $builder->getGrammar()->compileTruncate($builder)); + } + + public function testTruncateMethodWithPrefix() + { + $builder = $this->getBuilder(prefix: 'prefix_'); + $connection = $builder->getConnection(); + $connection->shouldReceive('statement')->once()->with('truncate table "prefix_users"', []); + $builder->from('users')->truncate(); + + $builder = $this->getSQLiteBuilder(prefix: 'prefix_'); + $builder->getConnection()->shouldReceive('getSchemaBuilder->parseSchemaAndTable')->andReturn([null, 'users']); + $builder->from('users'); + $this->assertEquals([ + 'delete from sqlite_sequence where name = ?' => ['prefix_users'], + 'delete from "prefix_users"' => [], + ], $builder->getGrammar()->compileTruncate($builder)); + } + + public function testTruncateMethodWithPrefixAndSchema() + { + $builder = $this->getBuilder(prefix: 'prefix_'); + $connection = $builder->getConnection(); + $connection->shouldReceive('statement')->once()->with('truncate table "my_schema"."prefix_users"', []); + $builder->from('my_schema.users')->truncate(); + + $builder = $this->getSQLiteBuilder(prefix: 'prefix_'); + $builder->getConnection()->shouldReceive('getSchemaBuilder->parseSchemaAndTable')->andReturn(['my_schema', 'users']); + $builder->from('my_schema.users'); + $this->assertEquals([ + 'delete from "my_schema".sqlite_sequence where name = ?' => ['prefix_users'], + 'delete from "my_schema"."prefix_users"' => [], + ], $builder->getGrammar()->compileTruncate($builder)); + } + + public function testPreserveAddsClosureToArray() + { + $builder = $this->getBuilder(); + $builder->beforeQuery(function () { + }); + $this->assertCount(1, $builder->beforeQueryCallbacks); + $this->assertInstanceOf(Closure::class, $builder->beforeQueryCallbacks[0]); + } + + public function testApplyPreserveCleansArray() + { + $builder = $this->getBuilder(); + $builder->beforeQuery(function () { + }); + $this->assertCount(1, $builder->beforeQueryCallbacks); + $builder->applyBeforeQueryCallbacks(); + $this->assertCount(0, $builder->beforeQueryCallbacks); + } + + public function testPreservedAreAppliedByToSql() + { + $builder = $this->getBuilder(); + $builder->beforeQuery(function ($builder) { + $builder->where('foo', 'bar'); + }); + $this->assertSame('select * where "foo" = ?', $builder->toSql()); + $this->assertEquals(['bar'], $builder->getBindings()); + } + + public function testPreservedAreAppliedByInsert() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('insert')->once()->with('insert into "users" ("email") values (?)', ['foo']); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->insert(['email' => 'foo']); + } + + public function testPreservedAreAppliedByInsertGetId() + { + $this->called = false; + $builder = $this->getBuilder(); + $builder->getProcessor()->shouldReceive('processInsertGetId')->once()->with($builder, 'insert into "users" ("email") values (?)', ['foo'], 'id'); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->insertGetId(['email' => 'foo'], 'id'); + } + + public function testPreservedAreAppliedByInsertUsing() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('affectingStatement')->once()->with('insert into "users" ("email") select *', []); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->insertUsing(['email'], $this->getBuilder()); + } + + public function testPreservedAreAppliedByUpsert() + { + $builder = $this->getMySqlBuilder(); + $builder->getConnection() + ->shouldReceive('getConfig')->with('use_upsert_alias')->andReturn(false) + ->shouldReceive('affectingStatement')->once()->with('insert into `users` (`email`) values (?) on duplicate key update `email` = values(`email`)', ['foo']); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->upsert(['email' => 'foo'], 'id'); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection() + ->shouldReceive('getConfig')->with('use_upsert_alias')->andReturn(true) + ->shouldReceive('affectingStatement')->once()->with('insert into `users` (`email`) values (?) as laravel_upsert_alias on duplicate key update `email` = `laravel_upsert_alias`.`email`', ['foo']); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->upsert(['email' => 'foo'], 'id'); + } + + public function testPreservedAreAppliedByUpdate() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update "users" set "email" = ? where "id" = ?', ['foo', 1]); + $builder->from('users')->beforeQuery(function ($builder) { + $builder->where('id', 1); + }); + $builder->update(['email' => 'foo']); + } + + public function testPreservedAreAppliedByDelete() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('delete')->once()->with('delete from "users"', []); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->delete(); + } + + public function testPreservedAreAppliedByTruncate() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('statement')->once()->with('truncate table "users"', []); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->truncate(); + } + + public function testPreservedAreAppliedByExists() + { + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select')->once()->with('select exists(select * from "users") as "exists"', [], true); + $builder->beforeQuery(function ($builder) { + $builder->from('users'); + }); + $builder->exists(); + } + + public function testPostgresInsertGetId() + { + $builder = $this->getPostgresBuilder(); + $builder->getProcessor()->shouldReceive('processInsertGetId')->once()->with($builder, 'insert into "users" ("email") values (?) returning "id"', ['foo'], 'id')->andReturn(1); + $result = $builder->from('users')->insertGetId(['email' => 'foo'], 'id'); + $this->assertEquals(1, $result); + } + + public function testMySqlWrapping() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users'); + $this->assertSame('select * from `users`', $builder->toSql()); + } + + public function testMySqlUpdateWrappingJson() + { + $connection = $this->createMock(Connection::class); + $grammar = new MySqlGrammar($connection); + $processor = m::mock(Processor::class); + + $connection->expects($this->once()) + ->method('update') + ->with( + 'update `users` set `name` = json_set(`name`, \'$."first_name"\', ?), `name` = json_set(`name`, \'$."last_name"\', ?) where `active` = ?', + ['John', 'Doe', 1] + ); + + $builder = new Builder($connection, $grammar, $processor); + + $builder->from('users')->where('active', '=', 1)->update(['name->first_name' => 'John', 'name->last_name' => 'Doe']); + } + + public function testMySqlUpdateWrappingNestedJson() + { + $connection = $this->createMock(Connection::class); + $grammar = new MySqlGrammar($connection); + $processor = m::mock(Processor::class); + + $connection->expects($this->once()) + ->method('update') + ->with( + 'update `users` set `meta` = json_set(`meta`, \'$."name"."first_name"\', ?), `meta` = json_set(`meta`, \'$."name"."last_name"\', ?) where `active` = ?', + ['John', 'Doe', 1] + ); + + $builder = new Builder($connection, $grammar, $processor); + + $builder->from('users')->where('active', '=', 1)->update(['meta->name->first_name' => 'John', 'meta->name->last_name' => 'Doe']); + } + + public function testMySqlUpdateWrappingJsonArray() + { + $connection = $this->createMock(Connection::class); + $grammar = new MySqlGrammar($connection); + $processor = m::mock(Processor::class); + + $connection->expects($this->once()) + ->method('update') + ->with( + 'update `users` set `options` = ?, `meta` = json_set(`meta`, \'$."tags"\', cast(? as json)), `group_id` = 45, `created_at` = ? where `active` = ?', + [ + json_encode(['2fa' => false, 'presets' => ['laravel', 'vue']]), + json_encode(['white', 'large']), + new DateTime('2019-08-06'), + 1, + ] + ); + + $builder = new Builder($connection, $grammar, $processor); + $builder->from('users')->where('active', 1)->update([ + 'options' => ['2fa' => false, 'presets' => ['laravel', 'vue']], + 'meta->tags' => ['white', 'large'], + 'group_id' => new Raw('45'), + 'created_at' => new DateTime('2019-08-06'), + ]); + } + + public function testMySqlUpdateWrappingJsonPathArrayIndex() + { + $connection = $this->createMock(Connection::class); + $grammar = new MySqlGrammar($connection); + $processor = m::mock(Processor::class); + + $connection->expects($this->once()) + ->method('update') + ->with( + 'update `users` set `options` = json_set(`options`, \'$[1]."2fa"\', false), `meta` = json_set(`meta`, \'$."tags"[0][2]\', ?) where `active` = ?', + [ + 'large', + 1, + ] + ); + + $builder = new Builder($connection, $grammar, $processor); + $builder->from('users')->where('active', 1)->update([ + 'options->[1]->2fa' => false, + 'meta->tags[0][2]' => 'large', + ]); + } + + public function testMySqlUpdateWithJsonPreparesBindingsCorrectly() + { + $connection = $this->getConnection(); + $grammar = new MySqlGrammar($connection); + $processor = m::mock(Processor::class); + + $connection->shouldReceive('update') + ->once() + ->with( + 'update `users` set `options` = json_set(`options`, \'$."enable"\', false), `updated_at` = ? where `id` = ?', + ['2015-05-26 22:02:06', 0] + ); + $builder = new Builder($connection, $grammar, $processor); + $builder->from('users')->where('id', '=', 0)->update(['options->enable' => false, 'updated_at' => '2015-05-26 22:02:06']); + + $connection->shouldReceive('update') + ->once() + ->with( + 'update `users` set `options` = json_set(`options`, \'$."size"\', ?), `updated_at` = ? where `id` = ?', + [45, '2015-05-26 22:02:06', 0] + ); + $builder = new Builder($connection, $grammar, $processor); + $builder->from('users')->where('id', '=', 0)->update(['options->size' => 45, 'updated_at' => '2015-05-26 22:02:06']); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update `users` set `options` = json_set(`options`, \'$."size"\', ?)', [null]); + $builder->from('users')->update(['options->size' => null]); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('update')->once()->with('update `users` set `options` = json_set(`options`, \'$."size"\', 45)', []); + $builder->from('users')->update(['options->size' => new Raw('45')]); + } + + public function testPostgresUpdateWrappingJson() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update') + ->with('update "users" set "options" = jsonb_set("options"::jsonb, \'{"name","first_name"}\', ?)', ['"John"']); + $builder->from('users')->update(['users.options->name->first_name' => 'John']); + + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update') + ->with('update "users" set "options" = jsonb_set("options"::jsonb, \'{"language"}\', \'null\')', []); + $builder->from('users')->update(['options->language' => new Raw("'null'")]); + } + + public function testPostgresUpdateWrappingJsonArray() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update') + ->with('update "users" set "options" = ?, "meta" = jsonb_set("meta"::jsonb, \'{"tags"}\', ?), "group_id" = 45, "created_at" = ?', [ + json_encode(['2fa' => false, 'presets' => ['laravel', 'vue']]), + json_encode(['white', 'large']), + new DateTime('2019-08-06'), + ]); + + $builder->from('users')->update([ + 'options' => ['2fa' => false, 'presets' => ['laravel', 'vue']], + 'meta->tags' => ['white', 'large'], + 'group_id' => new Raw('45'), + 'created_at' => new DateTime('2019-08-06'), + ]); + } + + public function testPostgresUpdateWrappingJsonPathArrayIndex() + { + $builder = $this->getPostgresBuilder(); + $builder->getConnection()->shouldReceive('update') + ->with('update "users" set "options" = jsonb_set("options"::jsonb, \'{1,"2fa"}\', ?), "meta" = jsonb_set("meta"::jsonb, \'{"tags",0,2}\', ?) where ("options"->1->\'2fa\')::jsonb = \'true\'::jsonb', [ + 'false', + '"large"', + ]); + + $builder->from('users')->where('options->[1]->2fa', true)->update([ + 'options->[1]->2fa' => false, + 'meta->tags[0][2]' => 'large', + ]); + } + + public function testSQLiteUpdateWrappingJsonArray() + { + $builder = $this->getSQLiteBuilder(); + + $builder->getConnection()->shouldReceive('update') + ->with('update "users" set "options" = ?, "group_id" = 45, "created_at" = ?', [ + json_encode(['2fa' => false, 'presets' => ['laravel', 'vue']]), + new DateTime('2019-08-06'), + ]); + + $builder->from('users')->update([ + 'options' => ['2fa' => false, 'presets' => ['laravel', 'vue']], + 'group_id' => new Raw('45'), + 'created_at' => new DateTime('2019-08-06'), + ]); + } + + public function testSQLiteUpdateWrappingNestedJsonArray() + { + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('update') + ->with('update "users" set "group_id" = 45, "created_at" = ?, "options" = json_patch(ifnull("options", json(\'{}\')), json(?))', [ + new DateTime('2019-08-06'), + json_encode(['name' => 'Taylor', 'security' => ['2fa' => false, 'presets' => ['laravel', 'vue']], 'sharing' => ['twitter' => 'username']]), + ]); + + $builder->from('users')->update([ + 'options->name' => 'Taylor', + 'group_id' => new Raw('45'), + 'options->security' => ['2fa' => false, 'presets' => ['laravel', 'vue']], + 'options->sharing->twitter' => 'username', + 'created_at' => new DateTime('2019-08-06'), + ]); + } + + public function testSQLiteUpdateWrappingJsonPathArrayIndex() + { + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->shouldReceive('update') + ->with('update "users" set "options" = json_patch(ifnull("options", json(\'{}\')), json(?)), "meta" = json_patch(ifnull("meta", json(\'{}\')), json(?)) where json_extract("options", \'$[1]."2fa"\') = true', [ + '{"[1]":{"2fa":false}}', + '{"tags[0][2]":"large"}', + ]); + + $builder->from('users')->where('options->[1]->2fa', true)->update([ + 'options->[1]->2fa' => false, + 'meta->tags[0][2]' => 'large', + ]); + } + + public function testMySqlWrappingJsonWithString() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('items->sku', '=', 'foo-bar'); + $this->assertSame('select * from `users` where json_unquote(json_extract(`items`, \'$."sku"\')) = ?', $builder->toSql()); + $this->assertCount(1, $builder->getRawBindings()['where']); + $this->assertSame('foo-bar', $builder->getRawBindings()['where'][0]); + } + + public function testMySqlWrappingJsonWithInteger() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('items->price', '=', 1); + $this->assertSame('select * from `users` where json_unquote(json_extract(`items`, \'$."price"\')) = ?', $builder->toSql()); + } + + public function testMySqlWrappingJsonWithDouble() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('items->price', '=', 1.5); + $this->assertSame('select * from `users` where json_unquote(json_extract(`items`, \'$."price"\')) = ?', $builder->toSql()); + } + + public function testMySqlWrappingJsonWithBoolean() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('items->available', '=', true); + $this->assertSame('select * from `users` where json_extract(`items`, \'$."available"\') = true', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where(new Raw("items->'$.available'"), '=', true); + $this->assertSame("select * from `users` where items->'$.available' = true", $builder->toSql()); + } + + public function testMySqlWrappingJsonWithBooleanAndIntegerThatLooksLikeOne() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('items->available', '=', true)->where('items->active', '=', false)->where('items->number_available', '=', 0); + $this->assertSame('select * from `users` where json_extract(`items`, \'$."available"\') = true and json_extract(`items`, \'$."active"\') = false and json_unquote(json_extract(`items`, \'$."number_available"\')) = ?', $builder->toSql()); + } + + public function testJsonPathEscaping() + { + $expectedWithJsonEscaped = <<<'SQL' +select json_unquote(json_extract(`json`, '$."''))#"')) +SQL; + + $builder = $this->getMySqlBuilder(); + $builder->select("json->'))#"); + $this->assertEquals($expectedWithJsonEscaped, $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select("json->\'))#"); + $this->assertEquals($expectedWithJsonEscaped, $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select("json->\\'))#"); + $this->assertEquals($expectedWithJsonEscaped, $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select("json->\\\'))#"); + $this->assertEquals($expectedWithJsonEscaped, $builder->toSql()); + } + + public function testMySqlWrappingJson() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereRaw('items->\'$."price"\' = 1'); + $this->assertSame('select * from `users` where items->\'$."price"\' = 1', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('items->price')->from('users')->where('users.items->price', '=', 1)->orderBy('items->price'); + $this->assertSame('select json_unquote(json_extract(`items`, \'$."price"\')) from `users` where json_unquote(json_extract(`users`.`items`, \'$."price"\')) = ? order by json_unquote(json_extract(`items`, \'$."price"\')) asc', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('items->price->in_usd', '=', 1); + $this->assertSame('select * from `users` where json_unquote(json_extract(`items`, \'$."price"."in_usd"\')) = ?', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('items->price->in_usd', '=', 1)->where('items->age', '=', 2); + $this->assertSame('select * from `users` where json_unquote(json_extract(`items`, \'$."price"."in_usd"\')) = ? and json_unquote(json_extract(`items`, \'$."age"\')) = ?', $builder->toSql()); + } + + public function testPostgresWrappingJson() + { + $builder = $this->getPostgresBuilder(); + $builder->select('items->price')->from('users')->where('users.items->price', '=', 1)->orderBy('items->price'); + $this->assertSame('select "items"->>\'price\' from "users" where "users"."items"->>\'price\' = ? order by "items"->>\'price\' asc', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('items->price->in_usd', '=', 1); + $this->assertSame('select * from "users" where "items"->\'price\'->>\'in_usd\' = ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('items->price->in_usd', '=', 1)->where('items->age', '=', 2); + $this->assertSame('select * from "users" where "items"->\'price\'->>\'in_usd\' = ? and "items"->>\'age\' = ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('items->prices->0', '=', 1)->where('items->age', '=', 2); + $this->assertSame('select * from "users" where "items"->\'prices\'->>0 = ? and "items"->>\'age\' = ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('items->available', '=', true); + $this->assertSame('select * from "users" where ("items"->\'available\')::jsonb = \'true\'::jsonb', $builder->toSql()); + } + + public function testSqlServerWrappingJson() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('items->price')->from('users')->where('users.items->price', '=', 1)->orderBy('items->price'); + $this->assertSame('select json_value([items], \'$."price"\') from [users] where json_value([users].[items], \'$."price"\') = ? order by json_value([items], \'$."price"\') asc', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('items->price->in_usd', '=', 1); + $this->assertSame('select * from [users] where json_value([items], \'$."price"."in_usd"\') = ?', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('items->price->in_usd', '=', 1)->where('items->age', '=', 2); + $this->assertSame('select * from [users] where json_value([items], \'$."price"."in_usd"\') = ? and json_value([items], \'$."age"\') = ?', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('items->available', '=', true); + $this->assertSame('select * from [users] where json_value([items], \'$."available"\') = \'true\'', $builder->toSql()); + } + + public function testSqliteWrappingJson() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('items->price')->from('users')->where('users.items->price', '=', 1)->orderBy('items->price'); + $this->assertSame('select json_extract("items", \'$."price"\') from "users" where json_extract("users"."items", \'$."price"\') = ? order by json_extract("items", \'$."price"\') asc', $builder->toSql()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('items->price->in_usd', '=', 1); + $this->assertSame('select * from "users" where json_extract("items", \'$."price"."in_usd"\') = ?', $builder->toSql()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('items->price->in_usd', '=', 1)->where('items->age', '=', 2); + $this->assertSame('select * from "users" where json_extract("items", \'$."price"."in_usd"\') = ? and json_extract("items", \'$."age"\') = ?', $builder->toSql()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('items->available', '=', true); + $this->assertSame('select * from "users" where json_extract("items", \'$."available"\') = true', $builder->toSql()); + } + + public function testSQLiteOrderBy() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->orderBy('email', 'desc'); + $this->assertSame('select * from "users" order by "email" desc', $builder->toSql()); + } + + public function testSqlServerLimitsAndOffsets() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->limit(10); + $this->assertSame('select top 10 * from [users]', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->offset(10)->orderBy('email', 'desc'); + $this->assertSame('select * from [users] order by [email] desc offset 10 rows', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->offset(10)->limit(10); + $this->assertSame('select * from [users] order by (SELECT 0) offset 10 rows fetch next 10 rows only', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->offset(11)->limit(10)->orderBy('email', 'desc'); + $this->assertSame('select * from [users] order by [email] desc offset 11 rows fetch next 10 rows only', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $subQuery = function ($query) { + return $query->select('created_at')->from('logins')->where('users.name', 'nameBinding')->whereColumn('user_id', 'users.id')->limit(1); + }; + $builder->select('*')->from('users')->where('email', 'emailBinding')->orderBy($subQuery)->offset(10)->limit(10); + $this->assertSame('select * from [users] where [email] = ? order by (select top 1 [created_at] from [logins] where [users].[name] = ? and [user_id] = [users].[id]) asc offset 10 rows fetch next 10 rows only', $builder->toSql()); + $this->assertEquals(['emailBinding', 'nameBinding'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->limit('foo'); + $this->assertSame('select * from [users]', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->limit('foo')->offset('bar'); + $this->assertSame('select * from [users]', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->offset('bar'); + $this->assertSame('select * from [users]', $builder->toSql()); + } + + public function testMySqlSoundsLikeOperator() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('name', 'sounds like', 'John Doe'); + $this->assertSame('select * from `users` where `name` sounds like ?', $builder->toSql()); + $this->assertEquals(['John Doe'], $builder->getBindings()); + } + + public function testBitwiseOperators() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('bar', '&', 1); + $this->assertSame('select * from "users" where "bar" & ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('bar', '#', 1); + $this->assertSame('select * from "users" where ("bar" # ?)::bool', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('range', '>>', '[2022-01-08 00:00:00,2022-01-09 00:00:00)'); + $this->assertSame('select * from "users" where ("range" >> ?)::bool', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('bar', '&', 1); + $this->assertSame('select * from [users] where ([bar] & ?) != 0', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->having('bar', '&', 1); + $this->assertSame('select * from "users" having "bar" & ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->having('bar', '#', 1); + $this->assertSame('select * from "users" having ("bar" # ?)::bool', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->having('range', '>>', '[2022-01-08 00:00:00,2022-01-09 00:00:00)'); + $this->assertSame('select * from "users" having ("range" >> ?)::bool', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->having('bar', '&', 1); + $this->assertSame('select * from [users] having ([bar] & ?) != 0', $builder->toSql()); + } + + public function testMergeWheresCanMergeWheresAndBindings() + { + $builder = $this->getBuilder(); + $builder->wheres = ['foo']; + $builder->mergeWheres(['wheres'], [12 => 'foo', 13 => 'bar']); + $this->assertEquals(['foo', 'wheres'], $builder->wheres); + $this->assertEquals(['foo', 'bar'], $builder->getBindings()); + } + + public function testPrepareValueAndOperator() + { + $builder = $this->getBuilder(); + [$value, $operator] = $builder->prepareValueAndOperator('>', '20'); + $this->assertSame('>', $value); + $this->assertSame('20', $operator); + + $builder = $this->getBuilder(); + [$value, $operator] = $builder->prepareValueAndOperator('>', '20', true); + $this->assertSame('20', $value); + $this->assertSame('=', $operator); + } + + public function testPrepareValueAndOperatorExpectException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Illegal operator and value combination.'); + + $builder = $this->getBuilder(); + $builder->prepareValueAndOperator(null, 'like'); + } + + public function testProvidingNullWithOperatorsBuildsCorrectly() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', null); + $this->assertSame('select * from "users" where "foo" is null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', '=', null); + $this->assertSame('select * from "users" where "foo" is null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', '!=', null); + $this->assertSame('select * from "users" where "foo" is not null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', '<>', null); + $this->assertSame('select * from "users" where "foo" is not null', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', null); + $this->assertSame('select * from "users" where "foo" is null', $builder->toSql()); + } + + public function testDynamicWhere() + { + $method = 'whereFooBarAndBazOrQux'; + $parameters = ['corge', 'waldo', 'fred']; + $builder = m::mock(Builder::class)->makePartial(); + + $builder->shouldReceive('where')->with('foo_bar', '=', $parameters[0], 'and')->once()->andReturnSelf(); + $builder->shouldReceive('where')->with('baz', '=', $parameters[1], 'and')->once()->andReturnSelf(); + $builder->shouldReceive('where')->with('qux', '=', $parameters[2], 'or')->once()->andReturnSelf(); + + $this->assertEquals($builder, $builder->dynamicWhere($method, $parameters)); + } + + public function testDynamicWhereIsNotGreedy() + { + $method = 'whereIosVersionAndAndroidVersionOrOrientation'; + $parameters = ['6.1', '4.2', 'Vertical']; + $builder = m::mock(Builder::class)->makePartial(); + + $builder->shouldReceive('where')->with('ios_version', '=', '6.1', 'and')->once()->andReturnSelf(); + $builder->shouldReceive('where')->with('android_version', '=', '4.2', 'and')->once()->andReturnSelf(); + $builder->shouldReceive('where')->with('orientation', '=', 'Vertical', 'or')->once()->andReturnSelf(); + + $builder->dynamicWhere($method, $parameters); + } + + public function testCallTriggersDynamicWhere() + { + $builder = $this->getBuilder(); + + $this->assertEquals($builder, $builder->whereFooAndBar('baz', 'qux')); + $this->assertCount(2, $builder->wheres); + } + + public function testBuilderThrowsExpectedExceptionWithUndefinedMethod() + { + $this->expectException(BadMethodCallException::class); + + $builder = $this->getBuilder(); + $builder->getConnection()->shouldReceive('select'); + $builder->getProcessor()->shouldReceive('processSelect')->andReturn([]); + + $builder->noValidMethodHere(); + } + + public function testMySqlLock() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock(); + $this->assertSame('select * from `foo` where `bar` = ? for update', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock(false); + $this->assertSame('select * from `foo` where `bar` = ? lock in share mode', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock('lock in share mode'); + $this->assertSame('select * from `foo` where `bar` = ? lock in share mode', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testPostgresLock() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock(); + $this->assertSame('select * from "foo" where "bar" = ? for update', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock(false); + $this->assertSame('select * from "foo" where "bar" = ? for share', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock('for key share'); + $this->assertSame('select * from "foo" where "bar" = ? for key share', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testSqlServerLock() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock(); + $this->assertSame('select * from [foo] with(rowlock,updlock,holdlock) where [bar] = ?', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock(false); + $this->assertSame('select * from [foo] with(rowlock,holdlock) where [bar] = ?', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock('with(holdlock)'); + $this->assertSame('select * from [foo] with(holdlock) where [bar] = ?', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testSelectWithLockUsesWritePdo() + { + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->getConnection()->shouldReceive('select')->once() + ->with(m::any(), m::any(), false); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock()->get(); + + $builder = $this->getMySqlBuilderWithProcessor(); + $builder->getConnection()->shouldReceive('select')->once() + ->with(m::any(), m::any(), false); + $builder->select('*')->from('foo')->where('bar', '=', 'baz')->lock(false)->get(); + } + + public function testBindingOrder() + { + $expectedSql = 'select * from "users" inner join "othertable" on "bar" = ? where "registered" = ? group by "city" having "population" > ? order by match ("foo") against(?)'; + $expectedBindings = ['foo', 1, 3, 'bar']; + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->join('othertable', function ($join) { + $join->where('bar', '=', 'foo'); + })->where('registered', 1)->groupBy('city')->having('population', '>', 3)->orderByRaw('match ("foo") against(?)', ['bar']); + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals($expectedBindings, $builder->getBindings()); + + // order of statements reversed + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->orderByRaw('match ("foo") against(?)', ['bar'])->having('population', '>', 3)->groupBy('city')->where('registered', 1)->join('othertable', function ($join) { + $join->where('bar', '=', 'foo'); + }); + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals($expectedBindings, $builder->getBindings()); + } + + public function testAddBindingWithArrayMergesBindings() + { + $builder = $this->getBuilder(); + $builder->addBinding(['foo', 'bar']); + $builder->addBinding(['baz']); + $this->assertEquals(['foo', 'bar', 'baz'], $builder->getBindings()); + } + + public function testAddBindingWithArrayMergesBindingsInCorrectOrder() + { + $builder = $this->getBuilder(); + $builder->addBinding(['bar', 'baz'], 'having'); + $builder->addBinding(['foo'], 'where'); + $this->assertEquals(['foo', 'bar', 'baz'], $builder->getBindings()); + } + + public function testAddBindingWithEnum() + { + $builder = $this->getBuilder(); + $builder->addBinding(IntegerStatus::done); + $builder->addBinding([NonBackedStatus::done]); + $this->assertEquals([2, 'done'], $builder->getBindings()); + } + + public function testMergeBuilders() + { + $builder = $this->getBuilder(); + $builder->addBinding(['foo', 'bar']); + $otherBuilder = $this->getBuilder(); + $otherBuilder->addBinding(['baz']); + $builder->mergeBindings($otherBuilder); + $this->assertEquals(['foo', 'bar', 'baz'], $builder->getBindings()); + } + + public function testMergeBuildersBindingOrder() + { + $builder = $this->getBuilder(); + $builder->addBinding('foo', 'where'); + $builder->addBinding('baz', 'having'); + $otherBuilder = $this->getBuilder(); + $otherBuilder->addBinding('bar', 'where'); + $builder->mergeBindings($otherBuilder); + $this->assertEquals(['foo', 'bar', 'baz'], $builder->getBindings()); + } + + public function testSubSelect() + { + $expectedSql = 'select "foo", "bar", (select "baz" from "two" where "subkey" = ?) as "sub" from "one" where "key" = ?'; + $expectedBindings = ['subval', 'val']; + + $builder = $this->getPostgresBuilder(); + $builder->from('one')->select(['foo', 'bar'])->where('key', '=', 'val'); + $builder->selectSub(function ($query) { + $query->from('two')->select('baz')->where('subkey', '=', 'subval'); + }, 'sub'); + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals($expectedBindings, $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->from('one')->select(['foo', 'bar'])->where('key', '=', 'val'); + $subBuilder = $this->getPostgresBuilder(); + $subBuilder->from('two')->select('baz')->where('subkey', '=', 'subval'); + $builder->selectSub($subBuilder, 'sub'); + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals($expectedBindings, $builder->getBindings()); + + $this->expectException(InvalidArgumentException::class); + $builder = $this->getPostgresBuilder(); + $builder->selectSub(['foo'], 'sub'); + } + + public function testSubSelectResetBindings() + { + $builder = $this->getPostgresBuilder(); + $builder->from('one')->selectSub(function ($query) { + $query->from('two')->select('baz')->where('subkey', '=', 'subval'); + }, 'sub'); + + $this->assertSame('select (select "baz" from "two" where "subkey" = ?) as "sub" from "one"', $builder->toSql()); + $this->assertEquals(['subval'], $builder->getBindings()); + + $builder->select('*'); + + $this->assertSame('select * from "one"', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + + public function testSelectExpression() + { + $builder = $this->getBuilder(); + $builder->from('one')->selectExpression(new Raw('1 + 1'), 'expr'); + + $this->assertSame('select (1 + 1) as "expr" from "one"', $builder->toSql()); + } + + public function testSelect() + { + $builder = $this->getBuilder(); + $builder->from('one')->select([ + 'two', + 'three' => 'threee as threeee', + 'four' => $this->getBuilder()->from('tbl')->select('col'), + 'five' => new Raw('1 + 1'), + ]); + + $this->assertSame('select "two", "threee" as "threeee", (select "col" from "tbl") as "four", 1 + 1 from "one"', $builder->toSql()); + } + + public function testSqlServerWhereDate() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereDate('created_at', '=', '2015-09-23'); + $this->assertSame('select * from [users] where cast([created_at] as date) = ?', $builder->toSql()); + $this->assertEquals([0 => '2015-09-23'], $builder->getBindings()); + } + + public function testUppercaseLeadingBooleansAreRemoved() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('name', '=', 'Taylor', 'AND'); + $this->assertSame('select * from "users" where "name" = ?', $builder->toSql()); + } + + public function testLowercaseLeadingBooleansAreRemoved() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('name', '=', 'Taylor', 'and'); + $this->assertSame('select * from "users" where "name" = ?', $builder->toSql()); + } + + public function testCaseInsensitiveLeadingBooleansAreRemoved() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('name', '=', 'Taylor', 'And'); + $this->assertSame('select * from "users" where "name" = ?', $builder->toSql()); + } + + public function testTableValuedFunctionAsTableInSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users()'); + $this->assertSame('select * from [users]()', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users(1,2)'); + $this->assertSame('select * from [users](1,2)', $builder->toSql()); + } + + public function testChunkWithLastChunkComplete() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = collect(['foo1', 'foo2']); + $chunk2 = collect(['foo3', 'foo4']); + $chunk3 = collect([]); + + $builder->shouldReceive('getOffset')->once()->andReturnNull(); + $builder->shouldReceive('getLimit')->once()->andReturnNull(); + $builder->shouldReceive('offset')->once()->with(0)->andReturnSelf(); + $builder->shouldReceive('offset')->once()->with(2)->andReturnSelf(); + $builder->shouldReceive('offset')->once()->with(4)->andReturnSelf(); + $builder->shouldReceive('limit')->times(3)->with(2)->andReturnSelf(); + $builder->shouldReceive('get')->times(3)->andReturn($chunk1, $chunk2, $chunk3); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk3); + + $builder->chunk(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }); + } + + public function testChunkWithLastChunkPartial() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = collect(['foo1', 'foo2']); + $chunk2 = collect(['foo3']); + + $builder->shouldReceive('getOffset')->once()->andReturnNull(); + $builder->shouldReceive('getLimit')->once()->andReturnNull(); + $builder->shouldReceive('offset')->once()->with(0)->andReturnSelf(); + $builder->shouldReceive('offset')->once()->with(2)->andReturnSelf(); + $builder->shouldReceive('limit')->twice()->with(2)->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2); + + $builder->chunk(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }); + } + + public function testChunkCanBeStoppedByReturningFalse() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = collect(['foo1', 'foo2']); + $chunk2 = collect(['foo3']); + $builder->shouldReceive('getOffset')->once()->andReturnNull(); + $builder->shouldReceive('getLimit')->once()->andReturnNull(); + $builder->shouldReceive('offset')->once()->with(0)->andReturnSelf(); + $builder->shouldReceive('limit')->once()->with(2)->andReturnSelf(); + $builder->shouldReceive('get')->times(1)->andReturn($chunk1); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk2); + + $builder->chunk(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + + return false; + }); + } + + public function testChunkWithCountZero() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('getOffset')->once()->andReturnNull(); + $builder->shouldReceive('getLimit')->once()->andReturnNull(); + $builder->shouldReceive('offset')->never(); + $builder->shouldReceive('limit')->never(); + $builder->shouldReceive('get')->never(); + + $builder->chunk(0, function () { + $this->fail('Should never be called.'); + }); + } + + public function testChunkByIdOnArrays() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = collect([['someIdField' => 1], ['someIdField' => 2]]); + $chunk2 = collect([['someIdField' => 10], ['someIdField' => 11]]); + $chunk3 = collect([]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 11, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(3)->andReturn($chunk1, $chunk2, $chunk3); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk3); + + $builder->chunkById(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }, 'someIdField'); + } + + public function testChunkPaginatesUsingIdWithLastChunkComplete() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = collect([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $chunk2 = collect([(object) ['someIdField' => 10], (object) ['someIdField' => 11]]); + $chunk3 = collect([]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 11, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(3)->andReturn($chunk1, $chunk2, $chunk3); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk3); + + $builder->chunkById(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }, 'someIdField'); + } + + public function testChunkPaginatesUsingIdWithLastChunkPartial() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = collect([(object) ['someIdField' => 1], (object) ['someIdField' => 2]]); + $chunk2 = collect([(object) ['someIdField' => 10]]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2); + + $builder->chunkById(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }, 'someIdField'); + } + + public function testChunkPaginatesUsingIdWithCountZero() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $builder->shouldReceive('forPageAfterId')->never(); + $builder->shouldReceive('get')->never(); + + $builder->chunkById(0, function () { + $this->fail('Should never be called.'); + }, 'someIdField'); + } + + public function testChunkPaginatesUsingIdWithAlias() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + + $chunk1 = collect([(object) ['table_id' => 1], (object) ['table_id' => 10]]); + $chunk2 = collect([]); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'table.id')->andReturnSelf(); + $builder->shouldReceive('forPageAfterId')->once()->with(2, 10, 'table.id')->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk2); + + $builder->chunkById(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }, 'table.id', 'table_id'); + } + + public function testChunkPaginatesUsingIdDesc() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'desc']; + + $chunk1 = collect([(object) ['someIdField' => 10], (object) ['someIdField' => 1]]); + $chunk2 = collect([]); + $builder->shouldReceive('forPageBeforeId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageBeforeId')->once()->with(2, 1, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk2); + + $builder->chunkByIdDesc(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }, 'someIdField'); + } + + public function testPaginate() + { + $perPage = 16; + $columns = ['test']; + $pageName = 'page-name'; + $page = 1; + $builder = $this->getMockQueryBuilder(); + $path = 'http://foo.bar?page=3'; + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('getCountForPagination')->once()->andReturn(2); + $builder->shouldReceive('forPage')->once()->with($page, $perPage)->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn($results); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->paginate($perPage, $columns, $pageName, $page); + + $this->assertEquals(new LengthAwarePaginator($results, 2, $perPage, $page, [ + 'path' => $path, + 'pageName' => $pageName, + ]), $result); + } + + public function testPaginateWithDefaultArguments() + { + $perPage = 15; + $pageName = 'page'; + $page = 1; + $builder = $this->getMockQueryBuilder(); + $path = 'http://foo.bar?page=3'; + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('getCountForPagination')->once()->andReturn(2); + $builder->shouldReceive('forPage')->once()->with($page, $perPage)->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn($results); + + Paginator::currentPageResolver(function () { + return 1; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->paginate(); + + $this->assertEquals(new LengthAwarePaginator($results, 2, $perPage, $page, [ + 'path' => $path, + 'pageName' => $pageName, + ]), $result); + } + + public function testPaginateWhenNoResults() + { + $perPage = 15; + $pageName = 'page'; + $page = 1; + $builder = $this->getMockQueryBuilder(); + $path = 'http://foo.bar?page=3'; + + $results = []; + + $builder->shouldReceive('getCountForPagination')->once()->andReturn(0); + $builder->shouldNotReceive('forPage'); + $builder->shouldNotReceive('get'); + + Paginator::currentPageResolver(function () { + return 1; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->paginate(); + + $this->assertEquals(new LengthAwarePaginator($results, 0, $perPage, $page, [ + 'path' => $path, + 'pageName' => $pageName, + ]), $result); + } + + public function testPaginateWithSpecificColumns() + { + $perPage = 16; + $columns = ['id', 'name']; + $pageName = 'page-name'; + $page = 1; + $builder = $this->getMockQueryBuilder(); + $path = 'http://foo.bar?page=3'; + + $results = collect([['id' => 3, 'name' => 'Taylor'], ['id' => 5, 'name' => 'Mohamed']]); + + $builder->shouldReceive('getCountForPagination')->once()->andReturn(2); + $builder->shouldReceive('forPage')->once()->with($page, $perPage)->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn($results); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->paginate($perPage, $columns, $pageName, $page); + + $this->assertEquals(new LengthAwarePaginator($results, 2, $perPage, $page, [ + 'path' => $path, + 'pageName' => $pageName, + ]), $result); + } + + public function testPaginateWithTotalOverride() + { + $perPage = 16; + $columns = ['id', 'name']; + $pageName = 'page-name'; + $page = 1; + $builder = $this->getMockQueryBuilder(); + $path = 'http://foo.bar?page=3'; + + $results = collect([['id' => 3, 'name' => 'Taylor'], ['id' => 5, 'name' => 'Mohamed']]); + + $builder->shouldReceive('getCountForPagination')->never(); + $builder->shouldReceive('forPage')->once()->with($page, $perPage)->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn($results); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->paginate($perPage, $columns, $pageName, $page, 10); + + $this->assertEquals(10, $result->total()); + } + + public function testCursorPaginate() + { + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['test' => 'bar']); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('test'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("test" > ?) order by "test" asc limit 17', + $builder->toSql()); + $this->assertEquals(['bar'], $builder->bindings['where']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateMultipleOrderColumns() + { + $perPage = 16; + $columns = ['test', 'another']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['test' => 'bar', 'another' => 'foo']); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('test')->orderBy('another'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo', 'another' => 1], ['test' => 'bar', 'another' => 2]]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("test" > ? or ("test" = ? and ("another" > ?))) order by "test" asc, "another" asc limit 17', + $builder->toSql() + ); + $this->assertEquals(['bar', 'bar', 'foo'], $builder->bindings['where']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test', 'another'], + ]), $result); + } + + public function testCursorPaginateWithDefaultArguments() + { + $perPage = 15; + $cursorName = 'cursor'; + $cursor = new Cursor(['test' => 'bar']); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('test'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("test" > ?) order by "test" asc limit 16', + $builder->toSql()); + $this->assertEquals(['bar'], $builder->bindings['where']); + + return $results; + }); + + CursorPaginator::currentCursorResolver(function () use ($cursor) { + return $cursor; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate(); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateWhenNoResults() + { + $perPage = 15; + $cursorName = 'cursor'; + $builder = $this->getMockQueryBuilder()->orderBy('test'); + $path = 'http://foo.bar?cursor=3'; + + $results = []; + + $builder->shouldReceive('get')->once()->andReturn($results); + + CursorPaginator::currentCursorResolver(function () { + return null; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate(); + + $this->assertEquals(new CursorPaginator($results, $perPage, null, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateWithSpecificColumns() + { + $perPage = 16; + $columns = ['id', 'name']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['id' => 2]); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('id'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor=3'; + + $results = collect([['id' => 3, 'name' => 'Taylor'], ['id' => 5, 'name' => 'Mohamed']]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("id" > ?) order by "id" asc limit 17', + $builder->toSql()); + $this->assertEquals([2], $builder->bindings['where']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['id'], + ]), $result); + } + + public function testCursorPaginateWithMixedOrders() + { + $perPage = 16; + $columns = ['foo', 'bar', 'baz']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['foo' => 1, 'bar' => 2, 'baz' => 3]); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('foo')->orderByDesc('bar')->orderBy('baz'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['foo' => 1, 'bar' => 2, 'baz' => 4], ['foo' => 1, 'bar' => 1, 'baz' => 1]]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("foo" > ? or ("foo" = ? and ("bar" < ? or ("bar" = ? and ("baz" > ?))))) order by "foo" asc, "bar" desc, "baz" asc limit 17', + $builder->toSql() + ); + $this->assertEquals([1, 1, 2, 2, 3], $builder->bindings['where']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['foo', 'bar', 'baz'], + ]), $result); + } + + public function testCursorPaginateWithDynamicColumnInSelectRaw() + { + $perPage = 15; + $cursorName = 'cursor'; + $cursor = new Cursor(['test' => 'bar']); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->select('*')->selectRaw('(CONCAT(firstname, \' \', lastname)) as test')->orderBy('test'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select *, (CONCAT(firstname, \' \', lastname)) as test from "foobar" where ((CONCAT(firstname, \' \', lastname)) > ?) order by "test" asc limit 16', + $builder->toSql()); + $this->assertEquals(['bar'], $builder->bindings['where']); + + return $results; + }); + + CursorPaginator::currentCursorResolver(function () use ($cursor) { + return $cursor; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate(); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateWithDynamicColumnWithCastInSelectRaw() + { + $perPage = 15; + $cursorName = 'cursor'; + $cursor = new Cursor(['test' => 'bar']); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->select('*')->selectRaw('(CAST(CONCAT(firstname, \' \', lastname) as VARCHAR)) as test')->orderBy('test'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select *, (CAST(CONCAT(firstname, \' \', lastname) as VARCHAR)) as test from "foobar" where ((CAST(CONCAT(firstname, \' \', lastname) as VARCHAR)) > ?) order by "test" asc limit 16', + $builder->toSql()); + $this->assertEquals(['bar'], $builder->bindings['where']); + + return $results; + }); + + CursorPaginator::currentCursorResolver(function () use ($cursor) { + return $cursor; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate(); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateWithDynamicColumnInSelectSub() + { + $perPage = 15; + $cursorName = 'cursor'; + $cursor = new Cursor(['test' => 'bar']); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->select('*')->selectSub('CONCAT(firstname, \' \', lastname)', 'test')->orderBy('test'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select *, (CONCAT(firstname, \' \', lastname)) as "test" from "foobar" where ((CONCAT(firstname, \' \', lastname)) > ?) order by "test" asc limit 16', + $builder->toSql()); + $this->assertEquals(['bar'], $builder->bindings['where']); + + return $results; + }); + + CursorPaginator::currentCursorResolver(function () use ($cursor) { + return $cursor; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate(); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateWithUnionWheres() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts]); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); + $builder->orderBy('created_at'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video'], + ['id' => 2, 'created_at' => now(), 'type' => 'news'], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "start_time" as "created_at", \'video\' as type from "videos" where ("start_time" > ?)) union (select "id", "created_at", \'news\' as type from "news" where ("created_at" > ?)) order by "created_at" asc limit 17', + $builder->toSql()); + $this->assertEquals([$ts], $builder->bindings['where']); + $this->assertEquals([$ts], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at'], + ]), $result); + } + + public function testCursorPaginateWithMultipleUnionsAndMultipleWheres() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts]); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')->where('extra', 'first')); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'podcast' as type")->from('podcasts')->where('extra', 'second')); + $builder->orderBy('created_at'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video'], + ['id' => 2, 'created_at' => now(), 'type' => 'news'], + ['id' => 3, 'created_at' => now(), 'type' => 'podcasts'], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "start_time" as "created_at", \'video\' as type from "videos" where ("start_time" > ?)) union (select "id", "created_at", \'news\' as type from "news" where "extra" = ? and ("created_at" > ?)) union (select "id", "created_at", \'podcast\' as type from "podcasts" where "extra" = ? and ("created_at" > ?)) order by "created_at" asc limit 17', + $builder->toSql()); + $this->assertEquals([$ts], $builder->bindings['where']); + $this->assertEquals(['first', $ts, 'second', $ts], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at'], + ]), $result); + } + + public function testCursorPaginateWithUnionMultipleWheresMultipleOrders() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['id', 'created_at', 'type']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['id' => 1, 'created_at' => $ts, 'type' => 'news']); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'start_time as created_at', 'type')->from('videos')->where('extra', 'first'); + $builder->union($this->getBuilder()->select('id', 'created_at', 'type')->from('news')->where('extra', 'second')); + $builder->union($this->getBuilder()->select('id', 'created_at', 'type')->from('podcasts')->where('extra', 'third')); + $builder->orderBy('id')->orderByDesc('created_at')->orderBy('type'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now()->addDay(), 'type' => 'video'], + ['id' => 1, 'created_at' => now(), 'type' => 'news'], + ['id' => 1, 'created_at' => now(), 'type' => 'podcast'], + ['id' => 2, 'created_at' => now(), 'type' => 'podcast'], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "start_time" as "created_at", "type" from "videos" where "extra" = ? and ("id" > ? or ("id" = ? and ("start_time" < ? or ("start_time" = ? and ("type" > ?)))))) union (select "id", "created_at", "type" from "news" where "extra" = ? and ("id" > ? or ("id" = ? and ("start_time" < ? or ("start_time" = ? and ("type" > ?)))))) union (select "id", "created_at", "type" from "podcasts" where "extra" = ? and ("id" > ? or ("id" = ? and ("start_time" < ? or ("start_time" = ? and ("type" > ?)))))) order by "id" asc, "created_at" desc, "type" asc limit 17', + $builder->toSql()); + $this->assertEquals(['first', 1, 1, $ts, $ts, 'news'], $builder->bindings['where']); + $this->assertEquals(['second', 1, 1, $ts, $ts, 'news', 'third', 1, 1, $ts, $ts, 'news'], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['id', 'created_at', 'type'], + ]), $result); + } + + public function testCursorPaginateWithUnionWheresWithRawOrderExpression() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts]); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'is_published', 'start_time as created_at')->selectRaw("'video' as type")->where('is_published', true)->from('videos'); + $builder->union($this->getBuilder()->select('id', 'is_published', 'created_at')->selectRaw("'news' as type")->where('is_published', true)->from('news')); + $builder->orderByRaw('case when (id = 3 and type="news" then 0 else 1 end)')->orderBy('created_at'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video', 'is_published' => true], + ['id' => 2, 'created_at' => now(), 'type' => 'news', 'is_published' => true], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "is_published", "start_time" as "created_at", \'video\' as type from "videos" where "is_published" = ? and ("start_time" > ?)) union (select "id", "is_published", "created_at", \'news\' as type from "news" where "is_published" = ? and ("created_at" > ?)) order by case when (id = 3 and type="news" then 0 else 1 end), "created_at" asc limit 17', + $builder->toSql()); + $this->assertEquals([true, $ts], $builder->bindings['where']); + $this->assertEquals([true, $ts], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at'], + ]), $result); + } + + public function testCursorPaginateWithUnionWheresReverseOrder() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts], false); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); + $builder->orderBy('created_at'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video'], + ['id' => 2, 'created_at' => now(), 'type' => 'news'], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "start_time" as "created_at", \'video\' as type from "videos" where ("start_time" < ?)) union (select "id", "created_at", \'news\' as type from "news" where ("created_at" < ?)) order by "created_at" desc limit 17', + $builder->toSql()); + $this->assertEquals([$ts], $builder->bindings['where']); + $this->assertEquals([$ts], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at'], + ]), $result); + } + + public function testCursorPaginateWithUnionWheresMultipleOrders() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts, 'id' => 1]); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); + $builder->orderByDesc('created_at')->orderBy('id'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video'], + ['id' => 2, 'created_at' => now(), 'type' => 'news'], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "start_time" as "created_at", \'video\' as type from "videos" where ("start_time" < ? or ("start_time" = ? and ("id" > ?)))) union (select "id", "created_at", \'news\' as type from "news" where ("created_at" < ? or ("created_at" = ? and ("id" > ?)))) order by "created_at" desc, "id" asc limit 17', + $builder->toSql()); + $this->assertEquals([$ts, $ts, 1], $builder->bindings['where']); + $this->assertEquals([$ts, $ts, 1], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at', 'id'], + ]), $result); + } + + public function testCursorPaginateWithUnionWheresAndAliassedOrderColumns() + { + $ts = now()->toDateTimeString(); + + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['created_at' => $ts]); + $builder = $this->getMockQueryBuilder(); + $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); + $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); + $builder->union($this->getBuilder()->select('id', 'init_at as created_at')->selectRaw("'podcast' as type")->from('podcasts')); + $builder->orderBy('created_at'); + + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([ + ['id' => 1, 'created_at' => now(), 'type' => 'video'], + ['id' => 2, 'created_at' => now(), 'type' => 'news'], + ['id' => 3, 'created_at' => now(), 'type' => 'podcast'], + ]); + + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results, $ts) { + $this->assertEquals( + '(select "id", "start_time" as "created_at", \'video\' as type from "videos" where ("start_time" > ?)) union (select "id", "created_at", \'news\' as type from "news" where ("created_at" > ?)) union (select "id", "init_at" as "created_at", \'podcast\' as type from "podcasts" where ("init_at" > ?)) order by "created_at" asc limit 17', + $builder->toSql()); + $this->assertEquals([$ts], $builder->bindings['where']); + $this->assertEquals([$ts, $ts], $builder->bindings['union']); + + return $results; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['created_at'], + ]), $result); + } + + public function testWhereExpression() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->where( + new class() implements ConditionExpression + { + public function getValue(\Illuminate\Database\Grammar $grammar) + { + return '1 = 1'; + } + } + ); + $this->assertSame('select * from "orders" where 1 = 1', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + + public function testWhereRowValues() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->whereRowValues(['last_update', 'order_number'], '<', [1, 2]); + $this->assertSame('select * from "orders" where ("last_update", "order_number") < (?, ?)', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->where('company_id', 1)->orWhereRowValues(['last_update', 'order_number'], '<', [1, 2]); + $this->assertSame('select * from "orders" where "company_id" = ? or ("last_update", "order_number") < (?, ?)', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->whereRowValues(['last_update', 'order_number'], '<', [1, new Raw('2')]); + $this->assertSame('select * from "orders" where ("last_update", "order_number") < (?, 2)', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereRowValuesArityMismatch() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The number of columns must match the number of values'); + + $builder = $this->getBuilder(); + $builder->select('*')->from('orders')->whereRowValues(['last_update'], '<', [1, 2]); + } + + public function testWhereJsonContainsMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonContains('options', ['en']); + $this->assertSame('select * from `users` where json_contains(`options`, ?)', $builder->toSql()); + $this->assertEquals(['["en"]'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonContains('users.options->languages', ['en']); + $this->assertSame('select * from `users` where json_contains(`users`.`options`, ?, \'$."languages"\')', $builder->toSql()); + $this->assertEquals(['["en"]'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContains('options->languages', new Raw("'[\"en\"]'")); + $this->assertSame('select * from `users` where `id` = ? or json_contains(`options`, \'["en"]\', \'$."languages"\')', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonOverlapsMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonOverlaps('options', ['en', 'fr']); + $this->assertSame('select * from `users` where json_overlaps(`options`, ?)', $builder->toSql()); + $this->assertEquals(['["en","fr"]'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonOverlaps('users.options->languages', ['en', 'fr']); + $this->assertSame('select * from `users` where json_overlaps(`users`.`options`, ?, \'$."languages"\')', $builder->toSql()); + $this->assertEquals(['["en","fr"]'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonOverlaps('options->languages', new Raw("'[\"en\", \"fr\"]'")); + $this->assertSame('select * from `users` where `id` = ? or json_overlaps(`options`, \'["en", "fr"]\', \'$."languages"\')', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonContainsPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonContains('options', ['en']); + $this->assertSame('select * from "users" where ("options")::jsonb @> ?', $builder->toSql()); + $this->assertEquals(['["en"]'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonContains('users.options->languages', ['en']); + $this->assertSame('select * from "users" where ("users"."options"->\'languages\')::jsonb @> ?', $builder->toSql()); + $this->assertEquals(['["en"]'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContains('options->languages', new Raw("'[\"en\"]'")); + $this->assertSame('select * from "users" where "id" = ? or ("options"->\'languages\')::jsonb @> \'["en"]\'', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonContainsSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonContains('options', 'en')->toSql(); + $this->assertSame('select * from "users" where exists (select 1 from json_each("options") where "json_each"."value" is ?)', $builder->toSql()); + $this->assertEquals(['en'], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonContains('users.options->language', 'en')->toSql(); + $this->assertSame('select * from "users" where exists (select 1 from json_each("users"."options", \'$."language"\') where "json_each"."value" is ?)', $builder->toSql()); + $this->assertEquals(['en'], $builder->getBindings()); + } + + public function testWhereJsonContainsSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereJsonContains('options', true); + $this->assertSame('select * from [users] where ? in (select [value] from openjson([options]))', $builder->toSql()); + $this->assertEquals(['true'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereJsonContains('users.options->languages', 'en'); + $this->assertSame('select * from [users] where ? in (select [value] from openjson([users].[options], \'$."languages"\'))', $builder->toSql()); + $this->assertEquals(['en'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContains('options->languages', new Raw("'en'")); + $this->assertSame('select * from [users] where [id] = ? or \'en\' in (select [value] from openjson([options], \'$."languages"\'))', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonDoesntContainMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContain('options->languages', ['en']); + $this->assertSame('select * from `users` where not json_contains(`options`, ?, \'$."languages"\')', $builder->toSql()); + $this->assertEquals(['["en"]'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContain('options->languages', new Raw("'[\"en\"]'")); + $this->assertSame('select * from `users` where `id` = ? or not json_contains(`options`, \'["en"]\', \'$."languages"\')', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonDoesntOverlapMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntOverlap('options->languages', ['en', 'fr']); + $this->assertSame('select * from `users` where not json_overlaps(`options`, ?, \'$."languages"\')', $builder->toSql()); + $this->assertEquals(['["en","fr"]'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntOverlap('options->languages', new Raw("'[\"en\", \"fr\"]'")); + $this->assertSame('select * from `users` where `id` = ? or not json_overlaps(`options`, \'["en", "fr"]\', \'$."languages"\')', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonDoesntContainPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContain('options->languages', ['en']); + $this->assertSame('select * from "users" where not ("options"->\'languages\')::jsonb @> ?', $builder->toSql()); + $this->assertEquals(['["en"]'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContain('options->languages', new Raw("'[\"en\"]'")); + $this->assertSame('select * from "users" where "id" = ? or not ("options"->\'languages\')::jsonb @> \'["en"]\'', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonDoesntContainSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContain('options', 'en')->toSql(); + $this->assertSame('select * from "users" where not exists (select 1 from json_each("options") where "json_each"."value" is ?)', $builder->toSql()); + $this->assertEquals(['en'], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContain('users.options->language', 'en')->toSql(); + $this->assertSame('select * from "users" where not exists (select 1 from json_each("users"."options", \'$."language"\') where "json_each"."value" is ?)', $builder->toSql()); + $this->assertEquals(['en'], $builder->getBindings()); + } + + public function testWhereJsonDoesntContainSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContain('options->languages', 'en'); + $this->assertSame('select * from [users] where not ? in (select [value] from openjson([options], \'$."languages"\'))', $builder->toSql()); + $this->assertEquals(['en'], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContain('options->languages', new Raw("'en'")); + $this->assertSame('select * from [users] where [id] = ? or not \'en\' in (select [value] from openjson([options], \'$."languages"\'))', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonContainsKeyMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages'); + $this->assertSame('select * from `users` where ifnull(json_contains_path(`users`.`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary'); + $this->assertSame('select * from `users` where ifnull(json_contains_path(`options`, \'one\', \'$."language"."primary"\'), 0)', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages'); + $this->assertSame('select * from `users` where `id` = ? or ifnull(json_contains_path(`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]'); + $this->assertSame('select * from `users` where ifnull(json_contains_path(`options`, \'one\', \'$."languages"[0][1]\'), 0)', $builder->toSql()); + } + + public function testWhereJsonContainsKeyPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages'); + $this->assertSame('select * from "users" where coalesce(("users"."options")::jsonb ?? \'languages\', false)', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary'); + $this->assertSame('select * from "users" where coalesce(("options"->\'language\')::jsonb ?? \'primary\', false)', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages'); + $this->assertSame('select * from "users" where "id" = ? or coalesce(("options")::jsonb ?? \'languages\', false)', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]'); + $this->assertSame('select * from "users" where case when jsonb_typeof(("options"->\'languages\'->0)::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\'->0)::jsonb) >= 2 else false end', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('options->languages[-1]'); + $this->assertSame('select * from "users" where case when jsonb_typeof(("options"->\'languages\')::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\')::jsonb) >= 1 else false end', $builder->toSql()); + } + + public function testWhereJsonContainsKeySqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages'); + $this->assertSame('select * from "users" where json_type("users"."options", \'$."languages"\') is not null', $builder->toSql()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary'); + $this->assertSame('select * from "users" where json_type("options", \'$."language"."primary"\') is not null', $builder->toSql()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages'); + $this->assertSame('select * from "users" where "id" = ? or json_type("options", \'$."languages"\') is not null', $builder->toSql()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]'); + $this->assertSame('select * from "users" where json_type("options", \'$."languages"[0][1]\') is not null', $builder->toSql()); + } + + public function testWhereJsonContainsKeySqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages'); + $this->assertSame('select * from [users] where \'languages\' in (select [key] from openjson([users].[options]))', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary'); + $this->assertSame('select * from [users] where \'primary\' in (select [key] from openjson([options], \'$."language"\'))', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages'); + $this->assertSame('select * from [users] where [id] = ? or \'languages\' in (select [key] from openjson([options]))', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]'); + $this->assertSame('select * from [users] where 1 in (select [key] from openjson([options], \'$."languages"[0]\'))', $builder->toSql()); + } + + public function testWhereJsonDoesntContainKeyMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages'); + $this->assertSame('select * from `users` where not ifnull(json_contains_path(`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages'); + $this->assertSame('select * from `users` where `id` = ? or not ifnull(json_contains_path(`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages[0][1]'); + $this->assertSame('select * from `users` where not ifnull(json_contains_path(`options`, \'one\', \'$."languages"[0][1]\'), 0)', $builder->toSql()); + } + + public function testWhereJsonDoesntContainKeyPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages'); + $this->assertSame('select * from "users" where not coalesce(("options")::jsonb ?? \'languages\', false)', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages'); + $this->assertSame('select * from "users" where "id" = ? or not coalesce(("options")::jsonb ?? \'languages\', false)', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages[0][1]'); + $this->assertSame('select * from "users" where not case when jsonb_typeof(("options"->\'languages\'->0)::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\'->0)::jsonb) >= 2 else false end', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages[-1]'); + $this->assertSame('select * from "users" where not case when jsonb_typeof(("options"->\'languages\')::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\')::jsonb) >= 1 else false end', $builder->toSql()); + } + + public function testWhereJsonDoesntContainKeySqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages'); + $this->assertSame('select * from "users" where not json_type("options", \'$."languages"\') is not null', $builder->toSql()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages'); + $this->assertSame('select * from "users" where "id" = ? or not json_type("options", \'$."languages"\') is not null', $builder->toSql()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages[0][1]'); + $this->assertSame('select * from "users" where "id" = ? or not json_type("options", \'$."languages"[0][1]\') is not null', $builder->toSql()); + } + + public function testWhereJsonDoesntContainKeySqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages'); + $this->assertSame('select * from [users] where not \'languages\' in (select [key] from openjson([options]))', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages'); + $this->assertSame('select * from [users] where [id] = ? or not \'languages\' in (select [key] from openjson([options]))', $builder->toSql()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages[0][1]'); + $this->assertSame('select * from [users] where [id] = ? or not 1 in (select [key] from openjson([options], \'$."languages"[0]\'))', $builder->toSql()); + } + + public function testWhereJsonLengthMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonLength('options', 0); + $this->assertSame('select * from `users` where json_length(`options`) = ?', $builder->toSql()); + $this->assertEquals([0], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereJsonLength('users.options->languages', '>', 0); + $this->assertSame('select * from `users` where json_length(`users`.`options`, \'$."languages"\') > ?', $builder->toSql()); + $this->assertEquals([0], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonLength('options->languages', new Raw('0')); + $this->assertSame('select * from `users` where `id` = ? or json_length(`options`, \'$."languages"\') = 0', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonLength('options->languages', '>', new Raw('0')); + $this->assertSame('select * from `users` where `id` = ? or json_length(`options`, \'$."languages"\') > 0', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonLengthPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonLength('options', 0); + $this->assertSame('select * from "users" where jsonb_array_length(("options")::jsonb) = ?', $builder->toSql()); + $this->assertEquals([0], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->whereJsonLength('users.options->languages', '>', 0); + $this->assertSame('select * from "users" where jsonb_array_length(("users"."options"->\'languages\')::jsonb) > ?', $builder->toSql()); + $this->assertEquals([0], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonLength('options->languages', new Raw('0')); + $this->assertSame('select * from "users" where "id" = ? or jsonb_array_length(("options"->\'languages\')::jsonb) = 0', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonLength('options->languages', '>', new Raw('0')); + $this->assertSame('select * from "users" where "id" = ? or jsonb_array_length(("options"->\'languages\')::jsonb) > 0', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonLengthSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonLength('options', 0); + $this->assertSame('select * from "users" where json_array_length("options") = ?', $builder->toSql()); + $this->assertEquals([0], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereJsonLength('users.options->languages', '>', 0); + $this->assertSame('select * from "users" where json_array_length("users"."options", \'$."languages"\') > ?', $builder->toSql()); + $this->assertEquals([0], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonLength('options->languages', new Raw('0')); + $this->assertSame('select * from "users" where "id" = ? or json_array_length("options", \'$."languages"\') = 0', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonLength('options->languages', '>', new Raw('0')); + $this->assertSame('select * from "users" where "id" = ? or json_array_length("options", \'$."languages"\') > 0', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testWhereJsonLengthSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereJsonLength('options', 0); + $this->assertSame('select * from [users] where (select count(*) from openjson([options])) = ?', $builder->toSql()); + $this->assertEquals([0], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->whereJsonLength('users.options->languages', '>', 0); + $this->assertSame('select * from [users] where (select count(*) from openjson([users].[options], \'$."languages"\')) > ?', $builder->toSql()); + $this->assertEquals([0], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonLength('options->languages', new Raw('0')); + $this->assertSame('select * from [users] where [id] = ? or (select count(*) from openjson([options], \'$."languages"\')) = 0', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + + $builder = $this->getSqlServerBuilder(); + $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonLength('options->languages', '>', new Raw('0')); + $this->assertSame('select * from [users] where [id] = ? or (select count(*) from openjson([options], \'$."languages"\')) > 0', $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function testFrom() + { + $builder = $this->getBuilder(); + $builder->from($this->getBuilder()->from('users'), 'u'); + $this->assertSame('select * from (select * from "users") as "u"', $builder->toSql()); + + $builder = $this->getBuilder(); + $eloquentBuilder = new EloquentBuilder($this->getBuilder()); + $builder->from($eloquentBuilder->from('users'), 'u'); + $this->assertSame('select * from (select * from "users") as "u"', $builder->toSql()); + } + + public function testFromSub() + { + $builder = $this->getBuilder(); + $builder->fromSub(function ($query) { + $query->select(new Raw('max(last_seen_at) as last_seen_at'))->from('user_sessions')->where('foo', '=', '1'); + }, 'sessions')->where('bar', '<', '10'); + $this->assertSame('select * from (select max(last_seen_at) as last_seen_at from "user_sessions" where "foo" = ?) as "sessions" where "bar" < ?', $builder->toSql()); + $this->assertEquals(['1', '10'], $builder->getBindings()); + + $this->expectException(InvalidArgumentException::class); + $builder = $this->getBuilder(); + $builder->fromSub(['invalid'], 'sessions')->where('bar', '<', '10'); + } + + public function testFromSubWithPrefix() + { + $builder = $this->getBuilder(prefix: 'prefix_'); + $builder->fromSub(function ($query) { + $query->select(new Raw('max(last_seen_at) as last_seen_at'))->from('user_sessions')->where('foo', '=', '1'); + }, 'sessions')->where('bar', '<', '10'); + $this->assertSame('select * from (select max(last_seen_at) as last_seen_at from "prefix_user_sessions" where "foo" = ?) as "prefix_sessions" where "bar" < ?', $builder->toSql()); + $this->assertEquals(['1', '10'], $builder->getBindings()); + } + + public function testFromSubWithoutBindings() + { + $builder = $this->getBuilder(); + $builder->fromSub(function ($query) { + $query->select(new Raw('max(last_seen_at) as last_seen_at'))->from('user_sessions'); + }, 'sessions'); + $this->assertSame('select * from (select max(last_seen_at) as last_seen_at from "user_sessions") as "sessions"', $builder->toSql()); + + $this->expectException(InvalidArgumentException::class); + $builder = $this->getBuilder(); + $builder->fromSub(['invalid'], 'sessions'); + } + + public function testFromRaw() + { + $builder = $this->getBuilder(); + $builder->fromRaw(new Raw('(select max(last_seen_at) as last_seen_at from "user_sessions") as "sessions"')); + $this->assertSame('select * from (select max(last_seen_at) as last_seen_at from "user_sessions") as "sessions"', $builder->toSql()); + } + + public function testFromRawOnSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->fromRaw('dbo.[SomeNameWithRoundBrackets (test)]'); + $this->assertSame('select * from dbo.[SomeNameWithRoundBrackets (test)]', $builder->toSql()); + } + + public function testFromRawWithWhereOnTheMainQuery() + { + $builder = $this->getBuilder(); + $builder->fromRaw(new Raw('(select max(last_seen_at) as last_seen_at from "sessions") as "last_seen_at"'))->where('last_seen_at', '>', '1520652582'); + $this->assertSame('select * from (select max(last_seen_at) as last_seen_at from "sessions") as "last_seen_at" where "last_seen_at" > ?', $builder->toSql()); + $this->assertEquals(['1520652582'], $builder->getBindings()); + } + + public function testFromQuestionMarkOperatorOnPostgres() + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('roles', '?', 'superuser'); + $this->assertSame('select * from "users" where "roles" ?? ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('roles', '?|', 'superuser'); + $this->assertSame('select * from "users" where "roles" ??| ?', $builder->toSql()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('roles', '?&', 'superuser'); + $this->assertSame('select * from "users" where "roles" ??& ?', $builder->toSql()); + } + + public function testUseIndexMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('foo')->from('users')->useIndex('test_index'); + $this->assertSame('select `foo` from `users` use index (test_index)', $builder->toSql()); + } + + public function testForceIndexMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('foo')->from('users')->forceIndex('test_index'); + $this->assertSame('select `foo` from `users` force index (test_index)', $builder->toSql()); + } + + public function testIgnoreIndexMySql() + { + $builder = $this->getMySqlBuilder(); + $builder->select('foo')->from('users')->ignoreIndex('test_index'); + $this->assertSame('select `foo` from `users` ignore index (test_index)', $builder->toSql()); + } + + public function testUseIndexSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('foo')->from('users')->useIndex('test_index'); + $this->assertSame('select "foo" from "users"', $builder->toSql()); + } + + public function testForceIndexSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('foo')->from('users')->forceIndex('test_index'); + $this->assertSame('select "foo" from "users" indexed by test_index', $builder->toSql()); + } + + public function testIgnoreIndexSqlite() + { + $builder = $this->getSQLiteBuilder(); + $builder->select('foo')->from('users')->ignoreIndex('test_index'); + $this->assertSame('select "foo" from "users"', $builder->toSql()); + } + + public function testUseIndexSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('foo')->from('users')->useIndex('test_index'); + $this->assertSame('select [foo] from [users]', $builder->toSql()); + } + + public function testForceIndexSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('foo')->from('users')->forceIndex('test_index'); + $this->assertSame('select [foo] from [users] with (index([test_index]))', $builder->toSql()); + } + + public function testIgnoreIndexSqlServer() + { + $builder = $this->getSqlServerBuilder(); + $builder->select('foo')->from('users')->ignoreIndex('test_index'); + $this->assertSame('select [foo] from [users]', $builder->toSql()); + } + + public function testClone() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users'); + $clone = $builder->clone()->where('email', 'foo'); + + $this->assertNotSame($builder, $clone); + $this->assertSame('select * from "users"', $builder->toSql()); + $this->assertSame('select * from "users" where "email" = ?', $clone->toSql()); + } + + public function testCloneWithout() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('email', 'foo')->orderBy('email'); + $clone = $builder->cloneWithout(['orders']); + + $this->assertSame('select * from "users" where "email" = ? order by "email" asc', $builder->toSql()); + $this->assertSame('select * from "users" where "email" = ?', $clone->toSql()); + } + + public function testCloneWithoutBindings() + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('email', 'foo')->orderBy('email'); + $clone = $builder->cloneWithout(['wheres'])->cloneWithoutBindings(['where']); + + $this->assertSame('select * from "users" where "email" = ? order by "email" asc', $builder->toSql()); + $this->assertEquals([0 => 'foo'], $builder->getBindings()); + + $this->assertSame('select * from "users" order by "email" asc', $clone->toSql()); + $this->assertEquals([], $clone->getBindings()); + } + + public function testToRawSql() + { + $connection = $this->getConnection(); + $connection->shouldReceive('prepareBindings') + ->with(['foo']) + ->andReturn(['foo']); + $grammar = m::mock(Grammar::class, [$connection])->makePartial(); + $grammar->shouldReceive('substituteBindingsIntoRawSql') + ->with('select * from "users" where "email" = ?', ['foo']) + ->andReturn('select * from "users" where "email" = \'foo\''); + $builder = new Builder($connection, $grammar, m::mock(Processor::class)); + $builder->select('*')->from('users')->where('email', 'foo'); + + $this->assertSame('select * from "users" where "email" = \'foo\'', $builder->toRawSql()); + } + + protected function getConnection(string $prefix = '') + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $connection->shouldReceive('getTablePrefix')->andReturn($prefix); + + return $connection; + } + + protected function getBuilder(string $prefix = '') + { + $connection = $this->getConnection(prefix: $prefix); + $grammar = new Grammar($connection); + $processor = m::mock(Processor::class); + + return new Builder($connection, $grammar, $processor); + } + + protected function getPostgresBuilder(string $prefix = '') + { + $connection = $this->getConnection(prefix: $prefix); + $grammar = new PostgresGrammar($connection); + $processor = m::mock(Processor::class); + + return new Builder($connection, $grammar, $processor); + } + + protected function getMySqlBuilder(string $prefix = '') + { + $connection = $this->getConnection(prefix: $prefix); + $grammar = new MySqlGrammar($connection); + $processor = m::mock(Processor::class); + + return new Builder($connection, $grammar, $processor); + } + + protected function getMariaDbBuilder(string $prefix = '') + { + $connection = $this->getConnection(prefix: $prefix); + $grammar = new MariaDbGrammar($connection); + $processor = m::mock(Processor::class); + + return new Builder($connection, $grammar, $processor); + } + + protected function getSQLiteBuilder(string $prefix = '') + { + $connection = $this->getConnection(prefix: $prefix); + $grammar = new SQLiteGrammar($connection); + $processor = m::mock(Processor::class); + + return new Builder($connection, $grammar, $processor); + } + + protected function getSqlServerBuilder(string $prefix = '') + { + $connection = $this->getConnection(prefix: $prefix); + $grammar = new SqlServerGrammar($connection); + $processor = m::mock(Processor::class); + + return new Builder($connection, $grammar, $processor); + } + + protected function getMySqlBuilderWithProcessor(string $prefix = '') + { + $connection = $this->getConnection(prefix: $prefix); + $grammar = new MySqlGrammar($connection); + $processor = new MySqlProcessor; + + return new Builder($connection, $grammar, $processor); + } + + protected function getPostgresBuilderWithProcessor(string $prefix = '') + { + $connection = $this->getConnection(prefix: $prefix); + $grammar = new PostgresGrammar($connection); + $processor = new PostgresProcessor; + + return new Builder($connection, $grammar, $processor); + } + + /** + * @return \Mockery\MockInterface|\Illuminate\Database\Query\Builder + */ + protected function getMockQueryBuilder() + { + return m::mock(Builder::class, [ + $connection = $this->getConnection(), + new Grammar($connection), + m::mock(Processor::class), + ])->makePartial(); + } +} diff --git a/tests/Database/Laravel/DatabaseQueryExceptionTest.php b/tests/Database/Laravel/DatabaseQueryExceptionTest.php new file mode 100755 index 000000000..256f3e38b --- /dev/null +++ b/tests/Database/Laravel/DatabaseQueryExceptionTest.php @@ -0,0 +1,165 @@ +getConnection(); + + $sql = 'SELECT * FROM huehue WHERE a = ? and hue = ?'; + $bindings = [1, 'br']; + + $expectedSql = "SELECT * FROM huehue WHERE a = 1 and hue = 'br'"; + + $pdoException = new PDOException('Mock SQL error'); + $exception = new QueryException($connection->getName(), $sql, $bindings, $pdoException); + + DB::shouldReceive('connection')->andReturn($connection); + $result = $exception->getRawSql(); + + $this->assertSame($expectedSql, $result); + } + + public function testIfItReturnsSameSqlWhenThereAreNoBindings() + { + $connection = $this->getConnection(); + + $sql = "SELECT * FROM huehue WHERE a = 1 and hue = 'br'"; + $bindings = []; + + $expectedSql = $sql; + + $pdoException = new PDOException('Mock SQL error'); + $exception = new QueryException($connection->getName(), $sql, $bindings, $pdoException); + + DB::shouldReceive('connection')->andReturn($connection); + $result = $exception->getRawSql(); + + $this->assertSame($expectedSql, $result); + } + + public function testMessageIncludesConnectionInfo() + { + $pdoException = new PDOException('SQLSTATE[HY000] [2002] No such file or directory'); + $exception = new QueryException('mysql::read', 'SELECT * FROM users', [], $pdoException, [ + 'driver' => 'mysql', + 'name' => 'mysql::read', + 'host' => '192.168.1.10', + 'port' => '3306', + 'database' => 'laravel_db', + 'unix_socket' => null, + ]); + + $this->assertStringContainsString('Host: 192.168.1.10', $exception->getMessage()); + $this->assertStringContainsString('Port: 3306', $exception->getMessage()); + $this->assertStringContainsString('Database: laravel_db', $exception->getMessage()); + $this->assertStringContainsString('Connection: mysql::read', $exception->getMessage()); + } + + public function testMessageIncludesUnixSocket() + { + $pdoException = new PDOException('SQLSTATE[HY000] [2002] No such file or directory'); + $exception = new QueryException('mysql', 'SELECT * FROM users', [], $pdoException, [ + 'driver' => 'mysql', + 'unix_socket' => '/tmp/mysql.sock', + 'database' => 'laravel_db', + ]); + + $this->assertStringContainsString('Socket: /tmp/mysql.sock', $exception->getMessage()); + $this->assertStringContainsString('Database: laravel_db', $exception->getMessage()); + $this->assertStringNotContainsString('Host:', $exception->getMessage()); + } + + public function testMessageHandlesArrayHosts() + { + $pdoException = new PDOException('SQLSTATE[HY000] [2002] No such file or directory'); + $exception = new QueryException('mysql::read', 'SELECT * FROM users', [], $pdoException, [ + 'driver' => 'mysql', + 'host' => ['192.168.1.10', '192.168.1.11'], + 'port' => '3306', + 'database' => 'laravel_db', + ]); + + $this->assertStringContainsString('Host: 192.168.1.10, 192.168.1.11', $exception->getMessage()); + } + + public function testMessageHandlesEmptyConnectionInfo() + { + $pdoException = new PDOException('SQLSTATE[HY000] [2002] No such file or directory'); + $exception = new QueryException('mysql', 'SELECT * FROM users', [], $pdoException, [ + 'driver' => 'mysql', + 'host' => '', + 'port' => '', + 'database' => '', + ]); + + $this->assertStringContainsString('Host: ,', $exception->getMessage()); + $this->assertStringContainsString('Database: ', $exception->getMessage()); + } + + public function testMessageForSqliteOnlyShowsDatabase() + { + $pdoException = new PDOException('SQLSTATE[HY000]: General error: 1 no such table'); + $exception = new QueryException('sqlite', 'SELECT * FROM users', [], $pdoException, [ + 'driver' => 'sqlite', + 'name' => 'sqlite', + 'host' => null, + 'port' => null, + 'database' => '/path/to/database.sqlite', + 'unix_socket' => null, + ]); + + $this->assertStringContainsString('Database: /path/to/database.sqlite', $exception->getMessage()); + $this->assertStringNotContainsString('Host:', $exception->getMessage()); + $this->assertStringNotContainsString('Port:', $exception->getMessage()); + } + + public function testGetConnectionInfoReturnsConnectionInfo() + { + $pdoException = new PDOException('Mock error'); + $connectionInfo = [ + 'driver' => 'mysql', + 'name' => 'mysql::read', + 'host' => '192.168.1.10', + 'port' => '3306', + 'database' => 'laravel_db', + 'unix_socket' => null, + ]; + $exception = new QueryException('mysql::read', 'SELECT * FROM users', [], $pdoException, $connectionInfo); + + $this->assertSame($connectionInfo, $exception->getConnectionDetails()); + } + + public function testBackwardCompatibilityWithoutConnectionInfo() + { + $pdoException = new PDOException('Mock SQL error'); + $exception = new QueryException('mysql', 'SELECT * FROM users WHERE id = ?', [1], $pdoException); + + $this->assertSame('Mock SQL error (Connection: mysql, SQL: SELECT * FROM users WHERE id = 1)', $exception->getMessage()); + $this->assertSame([], $exception->getConnectionDetails()); + } + + protected function getConnection() + { + $connection = m::mock(Connection::class); + + $grammar = new Grammar($connection); + + $connection->shouldReceive('getName')->andReturn('default'); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); + $connection->shouldReceive('escape')->with(1, false)->andReturn(1); + $connection->shouldReceive('escape')->with('br', false)->andReturn("'br'"); + + return $connection; + } +} diff --git a/tests/Database/Laravel/DatabaseQueryGrammarTest.php b/tests/Database/Laravel/DatabaseQueryGrammarTest.php new file mode 100644 index 000000000..ce0970f94 --- /dev/null +++ b/tests/Database/Laravel/DatabaseQueryGrammarTest.php @@ -0,0 +1,76 @@ +getMethod('whereRaw'); + $expressionArray = ['sql' => new Expression('select * from "users"')]; + + $rawQuery = $method->invoke($grammar, $builder, $expressionArray); + + $this->assertSame('select * from "users"', $rawQuery); + } + + public function testWhereRawReturnsStringWhenStringPassed() + { + $builder = m::mock(Builder::class); + $grammar = new Grammar(m::mock(Connection::class)); + $reflection = new ReflectionClass($grammar); + $method = $reflection->getMethod('whereRaw'); + $stringArray = ['sql' => 'select * from "users"']; + + $rawQuery = $method->invoke($grammar, $builder, $stringArray); + + $this->assertSame('select * from "users"', $rawQuery); + } + + public function testCompileOrdersAcceptsExpression() + { + $builder = m::mock(Builder::class); + $grammar = new Grammar(m::mock(Connection::class)); + + // compileOrders() calls $query->getGrammar() → return our $grammar + $builder->shouldReceive('getGrammar')->andReturn($grammar); + + $orders = [ + ['sql' => new Expression('length("name") desc')], // mimics orderByRaw(DB::raw(...)) + ]; + + $ref = new \ReflectionClass($grammar); + $method = $ref->getMethod('compileOrders'); // protected + $sql = $method->invoke($grammar, $builder, $orders); + + $this->assertSame('order by length("name") desc', strtolower($sql)); + } + + public function testCompileOrdersAcceptsExpressionWithPlaceholders() + { + $builder = m::mock(Builder::class); + $grammar = new Grammar(m::mock(Connection::class)); + $builder->shouldReceive('getGrammar')->andReturn($grammar); + + $orders = [ + ['sql' => new Expression('field(status, ?, ?) asc')], + ]; + + $ref = new \ReflectionClass($grammar); + $method = $ref->getMethod('compileOrders'); + $sql = $method->invoke($grammar, $builder, $orders); + + $this->assertSame('order by field(status, ?, ?) asc', strtolower($sql)); + } +} diff --git a/tests/Database/Laravel/DatabaseSQLiteBuilderTest.php b/tests/Database/Laravel/DatabaseSQLiteBuilderTest.php new file mode 100644 index 000000000..549376eec --- /dev/null +++ b/tests/Database/Laravel/DatabaseSQLiteBuilderTest.php @@ -0,0 +1,91 @@ +singleton('files', Filesystem::class); + + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Container::setInstance(null); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreateDatabase() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once(); + + $builder = new SQLiteBuilder($connection); + + File::shouldReceive('put') + ->once() + ->with('my_temporary_database_a', '') + ->andReturn(20); // bytes + + $this->assertTrue($builder->createDatabase('my_temporary_database_a')); + + File::shouldReceive('put') + ->once() + ->with('my_temporary_database_b', '') + ->andReturn(false); + + $this->assertFalse($builder->createDatabase('my_temporary_database_b')); + } + + public function testDropDatabaseIfExists() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once(); + + $builder = new SQLiteBuilder($connection); + + File::shouldReceive('exists') + ->once() + ->andReturn(true); + + File::shouldReceive('delete') + ->once() + ->with('my_temporary_database_b') + ->andReturn(true); + + $this->assertTrue($builder->dropDatabaseIfExists('my_temporary_database_b')); + + File::shouldReceive('exists') + ->once() + ->andReturn(false); + + $this->assertTrue($builder->dropDatabaseIfExists('my_temporary_database_c')); + + File::shouldReceive('exists') + ->once() + ->andReturn(true); + + File::shouldReceive('delete') + ->once() + ->with('my_temporary_database_c') + ->andReturn(false); + + $this->assertFalse($builder->dropDatabaseIfExists('my_temporary_database_c')); + } +} diff --git a/tests/Database/Laravel/DatabaseSQLiteProcessorTest.php b/tests/Database/Laravel/DatabaseSQLiteProcessorTest.php new file mode 100644 index 000000000..e45dab52d --- /dev/null +++ b/tests/Database/Laravel/DatabaseSQLiteProcessorTest.php @@ -0,0 +1,36 @@ + 'id', 'type' => 'INTEGER', 'nullable' => '0', 'default' => '', 'primary' => '1', 'extra' => 1], + ['name' => 'name', 'type' => 'varchar', 'nullable' => '1', 'default' => 'foo', 'primary' => '0', 'extra' => 1], + ['name' => 'is_active', 'type' => 'tinyint(1)', 'nullable' => '0', 'default' => '1', 'primary' => '0', 'extra' => 1], + ['name' => 'with/slash', 'type' => 'tinyint(1)', 'nullable' => '0', 'default' => '1', 'primary' => '0', 'extra' => 1], + ]; + $expected = [ + ['name' => 'id', 'type_name' => 'integer', 'type' => 'integer', 'collation' => null, 'nullable' => false, 'default' => '', 'auto_increment' => true, 'comment' => null, 'generation' => null], + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => true, 'default' => 'foo', 'auto_increment' => false, 'comment' => null, 'generation' => null], + ['name' => 'is_active', 'type_name' => 'tinyint', 'type' => 'tinyint(1)', 'collation' => null, 'nullable' => false, 'default' => '1', 'auto_increment' => false, 'comment' => null, 'generation' => null], + ['name' => 'with/slash', 'type_name' => 'tinyint', 'type' => 'tinyint(1)', 'collation' => null, 'nullable' => false, 'default' => '1', 'auto_increment' => false, 'comment' => null, 'generation' => null], + ]; + + $this->assertEquals($expected, $processor->processColumns($listing)); + + // convert listing to objects to simulate PDO::FETCH_CLASS + foreach ($listing as &$row) { + $row = (object) $row; + } + + $this->assertEquals($expected, $processor->processColumns($listing)); + } +} diff --git a/tests/Database/Laravel/DatabaseSQLiteQueryGrammarTest.php b/tests/Database/Laravel/DatabaseSQLiteQueryGrammarTest.php new file mode 100755 index 000000000..55b1cc8dc --- /dev/null +++ b/tests/Database/Laravel/DatabaseSQLiteQueryGrammarTest.php @@ -0,0 +1,25 @@ +shouldReceive('escape')->with('foo', false)->andReturn("'foo'"); + $grammar = new SQLiteGrammar($connection); + + $query = $grammar->substituteBindingsIntoRawSql( + 'select * from "users" where \'Hello\'\'World?\' IS NOT NULL AND "email" = ?', + ['foo'], + ); + + $this->assertSame('select * from "users" where \'Hello\'\'World?\' IS NOT NULL AND "email" = \'foo\'', $query); + } +} diff --git a/tests/Database/Laravel/DatabaseSQLiteSchemaGrammarTest.php b/tests/Database/Laravel/DatabaseSQLiteSchemaGrammarTest.php new file mode 100755 index 000000000..e8f3ace71 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSQLiteSchemaGrammarTest.php @@ -0,0 +1,1164 @@ +getConnection(), 'users'); + $blueprint->create(); + $blueprint->increments('id'); + $blueprint->string('email'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("id" integer primary key autoincrement not null, "email" varchar not null)', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->increments('id'); + $blueprint->string('email'); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $expected = [ + 'alter table "users" add column "id" integer primary key autoincrement not null', + 'alter table "users" add column "email" varchar not null', + ]; + $this->assertEquals($expected, $statements); + } + + public function testCreateTemporaryTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->temporary(); + $blueprint->increments('id'); + $blueprint->string('email'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create temporary table "users" ("id" integer primary key autoincrement not null, "email" varchar not null)', $statements[0]); + } + + public function testDropTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->drop(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop table "users"', $statements[0]); + } + + public function testDropTableIfExists() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropIfExists(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop table if exists "users"', $statements[0]); + } + + public function testDropUnique() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropUnique('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop index "foo"', $statements[0]); + } + + public function testDropIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dropIndex('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop index "foo"', $statements[0]); + } + + public function testDropIndexWithSchema() + { + $blueprint = new Blueprint($this->getConnection(), 'my_schema.users'); + $blueprint->dropIndex('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('drop index "my_schema"."foo"', $statements[0]); + } + + public function testDropColumn() + { + $db = new Manager; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => 'prefix_', + ]); + + $schema = $db->getConnection()->getSchemaBuilder(); + + $schema->create('users', function (Blueprint $table) { + $table->string('email'); + $table->string('name'); + }); + + $this->assertTrue($schema->hasTable('users')); + $this->assertTrue($schema->hasColumn('users', 'name')); + + $schema->table('users', function (Blueprint $table) { + $table->dropColumn('name'); + }); + + $this->assertFalse($schema->hasColumn('users', 'name')); + } + + public function testDropSpatialIndex() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The database driver in use does not support spatial indexes.'); + + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->dropSpatialIndex(['coordinates']); + $blueprint->toSql(); + } + + public function testRenameTable() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rename('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" rename to "foo"', $statements[0]); + } + + public function testRenameIndex() + { + $db = new Manager; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => 'prefix_', + ]); + + $schema = $db->getConnection()->getSchemaBuilder(); + + $schema->create('users', function (Blueprint $table) { + $table->string('name'); + $table->string('email'); + }); + + $schema->table('users', function (Blueprint $table) { + $table->index(['name', 'email'], 'index1'); + }); + + $indexes = $schema->getIndexListing('users'); + + $this->assertContains('index1', $indexes); + $this->assertNotContains('index2', $indexes); + + $schema->table('users', function (Blueprint $table) { + $table->renameIndex('index1', 'index2'); + }); + + $this->assertFalse($schema->hasIndex('users', 'index1')); + $this->assertTrue(collect($schema->getIndexes('users'))->contains( + fn ($index) => $index['name'] === 'index2' && $index['columns'] === ['name', 'email'] + )); + } + + public function testAddingPrimaryKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->string('foo')->primary(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("foo" varchar not null, primary key ("foo"))', $statements[0]); + } + + public function testAddingForeignKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->string('foo')->primary(); + $blueprint->string('order_id'); + $blueprint->foreign('order_id')->references('id')->on('orders'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("foo" varchar not null, "order_id" varchar not null, foreign key("order_id") references "orders"("id"), primary key ("foo"))', $statements[0]); + } + + public function testAddingUniqueKey() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->unique('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create unique index "bar" on "users" ("foo")', $statements[0]); + } + + public function testAddingIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->index(['foo', 'bar'], 'baz'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "baz" on "users" ("foo", "bar")', $statements[0]); + } + + public function testAddingUniqueKeyWithSchema() + { + $blueprint = new Blueprint($this->getConnection(), 'foo.users'); + $blueprint->unique('foo', 'bar'); + + $this->assertSame(['create unique index "foo"."bar" on "users" ("foo")'], $blueprint->toSql()); + } + + public function testAddingIndexWithSchema() + { + $blueprint = new Blueprint($this->getConnection(), 'foo.users'); + $blueprint->index(['foo', 'bar'], 'baz'); + + $this->assertSame(['create index "foo"."baz" on "users" ("foo", "bar")'], $blueprint->toSql()); + } + + public function testAddingSpatialIndex() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The database driver in use does not support spatial indexes.'); + + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->spatialIndex('coordinates'); + $blueprint->toSql(); + } + + public function testAddingFluentSpatialIndex() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The database driver in use does not support spatial indexes.'); + + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates')->spatialIndex(); + $blueprint->toSql(); + } + + public function testAddingRawIndex() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rawIndex('(function(column))', 'raw_index'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create index "raw_index" on "users" ((function(column)))', $statements[0]); + } + + public function testAddingIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->increments('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingSmallIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingMediumIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->id('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingForeignID() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getPostProcessor')->andReturn(new SQliteProcessor); + $connection->shouldReceive('selectFromWriteConnection')->andReturn([]); + $connection->shouldReceive('scalar')->andReturn(''); + + $blueprint = new Blueprint($connection, 'users'); + $foreignId = $blueprint->foreignId('foo'); + $blueprint->foreignId('company_id')->constrained(); + $blueprint->foreignId('laravel_idea_id')->constrained(); + $blueprint->foreignId('team_id')->references('id')->on('teams'); + $blueprint->foreignId('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql(); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignId); + $this->assertSame([ + 'alter table "users" add column "foo" integer not null', + 'alter table "users" add column "company_id" integer not null', + 'create table "__temp__users" ("foo" integer not null, "company_id" integer not null, foreign key("company_id") references "companies"("id"))', + 'insert into "__temp__users" ("foo", "company_id") select "foo", "company_id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + 'alter table "users" add column "laravel_idea_id" integer not null', + 'create table "__temp__users" ("foo" integer not null, "company_id" integer not null, "laravel_idea_id" integer not null, foreign key("company_id") references "companies"("id"), foreign key("laravel_idea_id") references "laravel_ideas"("id"))', + 'insert into "__temp__users" ("foo", "company_id", "laravel_idea_id") select "foo", "company_id", "laravel_idea_id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + 'alter table "users" add column "team_id" integer not null', + 'create table "__temp__users" ("foo" integer not null, "company_id" integer not null, "laravel_idea_id" integer not null, "team_id" integer not null, foreign key("company_id") references "companies"("id"), foreign key("laravel_idea_id") references "laravel_ideas"("id"), foreign key("team_id") references "teams"("id"))', + 'insert into "__temp__users" ("foo", "company_id", "laravel_idea_id", "team_id") select "foo", "company_id", "laravel_idea_id", "team_id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + 'alter table "users" add column "team_column_id" integer not null', + 'create table "__temp__users" ("foo" integer not null, "company_id" integer not null, "laravel_idea_id" integer not null, "team_id" integer not null, "team_column_id" integer not null, foreign key("company_id") references "companies"("id"), foreign key("laravel_idea_id") references "laravel_ideas"("id"), foreign key("team_id") references "teams"("id"), foreign key("team_column_id") references "teams"("id"))', + 'insert into "__temp__users" ("foo", "company_id", "laravel_idea_id", "team_id", "team_column_id") select "foo", "company_id", "laravel_idea_id", "team_id", "team_column_id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + ], $statements); + } + + public function testAddingForeignIdSpecifyingIndexNameInConstraint() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getPostProcessor')->andReturn(new SQliteProcessor); + $connection->shouldReceive('selectFromWriteConnection')->andReturn([]); + $connection->shouldReceive('scalar')->andReturn(''); + + $blueprint = new Blueprint($connection, 'users'); + $blueprint->foreignId('company_id')->constrained(indexName: 'my_index'); + + $statements = $blueprint->toSql(); + + $this->assertSame([ + 'alter table "users" add column "company_id" integer not null', + 'create table "__temp__users" ("company_id" integer not null, foreign key("company_id") references "companies"("id"))', + 'insert into "__temp__users" ("company_id") select "company_id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + ], $statements); + } + + public function testAddingBigIncrementingID() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigIncrements('id'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingString() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->string('foo', 100)->nullable()->default('bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar default \'bar\'', $statements[0]); + } + + public function testAddingText() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->text('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" text not null', $statements[0]); + } + + public function testAddingBigInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->bigInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->integer('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingMediumInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->mediumInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingTinyInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->tinyInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->tinyInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingSmallInteger() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallInteger('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->smallInteger('foo', true); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingFloat() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->float('foo', 5); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" float not null', $statements[0]); + } + + public function testAddingDouble() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->double('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" double not null', $statements[0]); + } + + public function testAddingDecimal() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->decimal('foo', 5, 2); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" numeric not null', $statements[0]); + } + + public function testAddingBoolean() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->boolean('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" tinyint(1) not null', $statements[0]); + } + + public function testAddingEnum() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->enum('role', ['member', 'admin']); + $blueprint->enum('status', Foo::cases()); + $statements = $blueprint->toSql(); + + $this->assertCount(2, $statements); + $this->assertSame('alter table "users" add column "role" varchar check ("role" in (\'member\', \'admin\')) not null', $statements[0]); + $this->assertSame('alter table "users" add column "status" varchar check ("status" in (\'bar\')) not null', $statements[1]); + } + + public function testAddingJson() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->json('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" text not null', $statements[0]); + } + + public function testAddingNativeJson() + { + $connection = m::mock(Connection::class); + $connection + ->shouldReceive('getTablePrefix')->andReturn('') + ->shouldReceive('getConfig')->once()->with('use_native_json')->andReturn(true) + ->shouldReceive('getSchemaGrammar')->andReturn($this->getGrammar($connection)) + ->shouldReceive('getSchemaBuilder')->andReturn($this->getBuilder()) + ->shouldReceive('getServerVersion')->andReturn('3.35') + ->getMock(); + + $blueprint = new Blueprint($connection, 'users'); + $blueprint->json('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" json not null', $statements[0]); + } + + public function testAddingJsonb() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->jsonb('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" text not null', $statements[0]); + } + + public function testAddingNativeJsonb() + { + $connection = m::mock(Connection::class); + $connection + ->shouldReceive('getTablePrefix')->andReturn('') + ->shouldReceive('getConfig')->once()->with('use_native_jsonb')->andReturn(true) + ->shouldReceive('getSchemaGrammar')->andReturn($this->getGrammar($connection)) + ->shouldReceive('getSchemaBuilder')->andReturn($this->getBuilder()) + ->shouldReceive('getServerVersion')->andReturn('3.35') + ->getMock(); + + $blueprint = new Blueprint($connection, 'users'); + $blueprint->jsonb('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" jsonb not null', $statements[0]); + } + + public function testAddingDate() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->date('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" date not null', $statements[0]); + } + + public function testAddingDateWithDefaultCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->date('foo')->useCurrent(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" date not null default CURRENT_DATE', $statements[0]); + } + + public function testAddingYear() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->year('birth_year'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "birth_year" integer not null', $statements[0]); + } + + public function testAddingYearWithDefaultCurrent() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->year('birth_year')->useCurrent(); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "birth_year" integer not null default (CAST(strftime(\'%Y\', \'now\') AS INTEGER))', $statements[0]); + } + + public function testAddingDateTime() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingDateTimeWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTime('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingDateTimeTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTimeTz('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingDateTimeTzWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->dateTimeTz('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTime() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->time('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" time not null', $statements[0]); + } + + public function testAddingTimeWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->time('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" time not null', $statements[0]); + } + + public function testAddingTimeTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timeTz('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" time not null', $statements[0]); + } + + public function testAddingTimeTzWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timeTz('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" time not null', $statements[0]); + } + + public function testAddingTimestamp() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTimestampWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamp('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTimestampTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampTz('created_at'); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTimestampTzWithPrecision() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampTz('created_at', 1); + $statements = $blueprint->toSql(); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTimestamps() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestamps(); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertEquals([ + 'alter table "users" add column "created_at" datetime', + 'alter table "users" add column "updated_at" datetime', + ], $statements); + } + + public function testAddingTimestampsTz() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->timestampsTz(); + $statements = $blueprint->toSql(); + $this->assertCount(2, $statements); + $this->assertEquals([ + 'alter table "users" add column "created_at" datetime', + 'alter table "users" add column "updated_at" datetime', + ], $statements); + } + + public function testAddingRememberToken() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->rememberToken(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "remember_token" varchar', $statements[0]); + } + + public function testAddingBinary() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->binary('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" blob not null', $statements[0]); + } + + public function testAddingUuid() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->uuid('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + } + + public function testAddingUuidDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->uuid(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "uuid" varchar not null', $statements[0]); + } + + public function testAddingForeignUuid() + { + $connection = $this->getConnection(); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getPostProcessor')->andReturn(new SQliteProcessor); + $connection->shouldReceive('selectFromWriteConnection')->andReturn([]); + $connection->shouldReceive('scalar')->andReturn(''); + + $blueprint = new Blueprint($connection, 'users'); + $foreignUuid = $blueprint->foreignUuid('foo'); + $blueprint->foreignUuid('company_id')->constrained(); + $blueprint->foreignUuid('laravel_idea_id')->constrained(); + $blueprint->foreignUuid('team_id')->references('id')->on('teams'); + $blueprint->foreignUuid('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql(); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignUuid); + $this->assertSame([ + 'alter table "users" add column "foo" varchar not null', + 'alter table "users" add column "company_id" varchar not null', + 'create table "__temp__users" ("foo" varchar not null, "company_id" varchar not null, foreign key("company_id") references "companies"("id"))', + 'insert into "__temp__users" ("foo", "company_id") select "foo", "company_id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + 'alter table "users" add column "laravel_idea_id" varchar not null', + 'create table "__temp__users" ("foo" varchar not null, "company_id" varchar not null, "laravel_idea_id" varchar not null, foreign key("company_id") references "companies"("id"), foreign key("laravel_idea_id") references "laravel_ideas"("id"))', + 'insert into "__temp__users" ("foo", "company_id", "laravel_idea_id") select "foo", "company_id", "laravel_idea_id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + 'alter table "users" add column "team_id" varchar not null', + 'create table "__temp__users" ("foo" varchar not null, "company_id" varchar not null, "laravel_idea_id" varchar not null, "team_id" varchar not null, foreign key("company_id") references "companies"("id"), foreign key("laravel_idea_id") references "laravel_ideas"("id"), foreign key("team_id") references "teams"("id"))', + 'insert into "__temp__users" ("foo", "company_id", "laravel_idea_id", "team_id") select "foo", "company_id", "laravel_idea_id", "team_id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + 'alter table "users" add column "team_column_id" varchar not null', + 'create table "__temp__users" ("foo" varchar not null, "company_id" varchar not null, "laravel_idea_id" varchar not null, "team_id" varchar not null, "team_column_id" varchar not null, foreign key("company_id") references "companies"("id"), foreign key("laravel_idea_id") references "laravel_ideas"("id"), foreign key("team_id") references "teams"("id"), foreign key("team_column_id") references "teams"("id"))', + 'insert into "__temp__users" ("foo", "company_id", "laravel_idea_id", "team_id", "team_column_id") select "foo", "company_id", "laravel_idea_id", "team_id", "team_column_id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + ], $statements); + } + + public function testAddingIpAddress() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->ipAddress('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + } + + public function testAddingIpAddressDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->ipAddress(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "ip_address" varchar not null', $statements[0]); + } + + public function testAddingMacAddress() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->macAddress('foo'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + } + + public function testAddingMacAddressDefaultsColumnName() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->macAddress(); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "mac_address" varchar not null', $statements[0]); + } + + public function testAddingGeometry() + { + $blueprint = new Blueprint($this->getConnection(), 'geo'); + $blueprint->geometry('coordinates'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry not null', $statements[0]); + } + + public function testAddingGeneratedColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'products'); + $blueprint->create(); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('"price" - 5'); + $blueprint->integer('discounted_stored')->storedAs('"price" - 5'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "products" ("price" integer not null, "discounted_virtual" integer as ("price" - 5), "discounted_stored" integer as ("price" - 5) stored)', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'products'); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('"price" - 5')->nullable(false); + $blueprint->integer('discounted_stored')->storedAs('"price" - 5')->nullable(false); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $expected = [ + 'alter table "products" add column "price" integer not null', + 'alter table "products" add column "discounted_virtual" integer not null as ("price" - 5)', + 'alter table "products" add column "discounted_stored" integer not null as ("price" - 5) stored', + ]; + $this->assertSame($expected, $statements); + } + + public function testAddingGeneratedColumnByExpression() + { + $blueprint = new Blueprint($this->getConnection(), 'products'); + $blueprint->create(); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs(new Expression('"price" - 5')); + $blueprint->integer('discounted_stored')->storedAs(new Expression('"price" - 5')); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "products" ("price" integer not null, "discounted_virtual" integer as ("price" - 5), "discounted_stored" integer as ("price" - 5) stored)', $statements[0]); + } + + public function testGrammarsAreMacroable() + { + // compileReplace macro. + $this->getGrammar()::macro('compileReplace', function () { + return true; + }); + + $c = $this->getGrammar()::compileReplace(); + + $this->assertTrue($c); + } + + public function testCreateTableWithVirtualAsColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->virtualAs('my_column'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_column" varchar not null, "my_other_column" varchar as (my_column))', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAsJson('my_json_column->some_attribute'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_json_column" varchar not null, "my_other_column" varchar as (json_extract("my_json_column", \'$."some_attribute"\')))', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAsJson('my_json_column->some_attribute->nested'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_json_column" varchar not null, "my_other_column" varchar as (json_extract("my_json_column", \'$."some_attribute"."nested"\')))', $statements[0]); + } + + public function testCreateTableWithVirtualAsColumnWhenJsonColumnHasArrayKey() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $blueprint = new Blueprint($conn, 'users'); + $blueprint->create(); + $blueprint->string('my_json_column')->virtualAsJson('my_json_column->foo[0][1]'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame("create table \"users\" (\"my_json_column\" varchar as (json_extract(\"my_json_column\", '$.\"foo\"[0][1]')))", $statements[0]); + } + + public function testCreateTableWithStoredAsColumn() + { + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->storedAs('my_column'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_column" varchar not null, "my_other_column" varchar as (my_column) stored)', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAsJson('my_json_column->some_attribute'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_json_column" varchar not null, "my_other_column" varchar as (json_extract("my_json_column", \'$."some_attribute"\')) stored)', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), 'users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAsJson('my_json_column->some_attribute->nested'); + + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_json_column" varchar not null, "my_other_column" varchar as (json_extract("my_json_column", \'$."some_attribute"."nested"\')) stored)', $statements[0]); + } + + public function testDroppingColumnsWorks() + { + $blueprint = new Blueprint($this->getConnection(), 'users', function ($table) { + $table->dropColumn('name'); + }); + + $this->assertEquals(['alter table "users" drop column "name"'], $blueprint->toSql()); + } + + public function testRenamingAndChangingColumnsWork() + { + $builder = mock(SQLiteBuilder::class) + ->makePartial() + ->shouldReceive('getColumns')->andReturn([ + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ['name' => 'age', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ]) + ->shouldReceive('getIndexes')->andReturn([]) + ->shouldReceive('getForeignKeys')->andReturn([]) + ->getMock(); + + $connection = $this->getConnection(builder: $builder); + $connection->shouldReceive('scalar')->with('pragma foreign_keys')->andReturn(false); + + $blueprint = new Blueprint($connection, 'users'); + $blueprint->renameColumn('name', 'first_name'); + $blueprint->integer('age')->change(); + + $this->assertEquals([ + 'alter table "users" rename column "name" to "first_name"', + 'create table "__temp__users" ("first_name" varchar not null, "age" integer not null)', + 'insert into "__temp__users" ("first_name", "age") select "first_name", "age" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + ], $blueprint->toSql()); + } + + public function testRenamingAndChangingColumnsWorkWithSchema() + { + $builder = mock(SQLiteBuilder::class) + ->makePartial() + ->shouldReceive('getColumns')->andReturn([ + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ['name' => 'age', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ]) + ->shouldReceive('getIndexes')->andReturn([]) + ->shouldReceive('getForeignKeys')->andReturn([]) + ->getMock(); + + $connection = $this->getConnection(builder: $builder); + $connection->shouldReceive('scalar')->with('pragma foreign_keys')->andReturn(false); + + $blueprint = new Blueprint($connection, 'my_schema.users'); + $blueprint->renameColumn('name', 'first_name'); + $blueprint->integer('age')->change(); + + $this->assertEquals([ + 'alter table "my_schema"."users" rename column "name" to "first_name"', + 'create table "my_schema"."__temp__users" ("first_name" varchar not null, "age" integer not null)', + 'insert into "my_schema"."__temp__users" ("first_name", "age") select "first_name", "age" from "my_schema"."users"', + 'drop table "my_schema"."users"', + 'alter table "my_schema"."__temp__users" rename to "users"', + ], $blueprint->toSql()); + } + + protected function getConnection( + ?SQLiteGrammar $grammar = null, + ?SQLiteBuilder $builder = null, + $prefix = '' + ) { + $connection = m::mock(Connection::class); + $grammar ??= $this->getGrammar($connection); + $builder ??= $this->getBuilder(); + + return $connection + ->shouldReceive('getTablePrefix')->andReturn($prefix) + ->shouldReceive('getConfig')->andReturn(null) + ->shouldReceive('getSchemaGrammar')->andReturn($grammar) + ->shouldReceive('getSchemaBuilder')->andReturn($builder) + ->shouldReceive('getServerVersion')->andReturn('3.35') + ->getMock(); + } + + public function getGrammar(?Connection $connection = null) + { + return new SQLiteGrammar($connection ?? $this->getConnection()); + } + + public function getBuilder() + { + return mock(SQLiteBuilder::class) + ->makePartial() + ->shouldReceive('getColumns')->andReturn([]) + ->shouldReceive('getIndexes')->andReturn([]) + ->shouldReceive('getForeignKeys')->andReturn([]) + ->getMock(); + } +} diff --git a/tests/Database/Laravel/DatabaseSchemaBlueprintTest.php b/tests/Database/Laravel/DatabaseSchemaBlueprintTest.php new file mode 100755 index 000000000..e83013134 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSchemaBlueprintTest.php @@ -0,0 +1,700 @@ +getConnection(); + $conn->shouldReceive('statement')->once()->with('foo'); + $conn->shouldReceive('statement')->once()->with('bar'); + $blueprint = $this->getMockBuilder(Blueprint::class)->onlyMethods(['toSql'])->setConstructorArgs([$conn, 'users'])->getMock(); + $blueprint->expects($this->once())->method('toSql')->willReturn(['foo', 'bar']); + + $blueprint->build(); + } + + public function testIndexDefaultNames() + { + $blueprint = $this->getBlueprint(table: 'users'); + $blueprint->unique(['foo', 'bar']); + $commands = $blueprint->getCommands(); + $this->assertSame('users_foo_bar_unique', $commands[0]->index); + + $blueprint = $this->getBlueprint(table: 'users'); + $blueprint->index('foo'); + $commands = $blueprint->getCommands(); + $this->assertSame('users_foo_index', $commands[0]->index); + + $blueprint = $this->getBlueprint(table: 'geo'); + $blueprint->spatialIndex('coordinates'); + $commands = $blueprint->getCommands(); + $this->assertSame('geo_coordinates_spatialindex', $commands[0]->index); + } + + public function testIndexDefaultNamesWhenPrefixSupplied() + { + $blueprint = $this->getBlueprint(table: 'users', prefix: 'prefix_'); + $blueprint->unique(['foo', 'bar']); + $commands = $blueprint->getCommands(); + $this->assertSame('prefix_users_foo_bar_unique', $commands[0]->index); + + $blueprint = $this->getBlueprint(table: 'users', prefix: 'prefix_'); + $blueprint->index('foo'); + $commands = $blueprint->getCommands(); + $this->assertSame('prefix_users_foo_index', $commands[0]->index); + + $blueprint = $this->getBlueprint(table: 'geo', prefix: 'prefix_'); + $blueprint->spatialIndex('coordinates'); + $commands = $blueprint->getCommands(); + $this->assertSame('prefix_geo_coordinates_spatialindex', $commands[0]->index); + } + + public function testDropIndexDefaultNames() + { + $blueprint = $this->getBlueprint(table: 'users'); + $blueprint->dropUnique(['foo', 'bar']); + $commands = $blueprint->getCommands(); + $this->assertSame('users_foo_bar_unique', $commands[0]->index); + + $blueprint = $this->getBlueprint(table: 'users'); + $blueprint->dropIndex(['foo']); + $commands = $blueprint->getCommands(); + $this->assertSame('users_foo_index', $commands[0]->index); + + $blueprint = $this->getBlueprint(table: 'geo'); + $blueprint->dropSpatialIndex(['coordinates']); + $commands = $blueprint->getCommands(); + $this->assertSame('geo_coordinates_spatialindex', $commands[0]->index); + } + + public function testDropIndexDefaultNamesWhenPrefixSupplied() + { + $blueprint = $this->getBlueprint(table: 'users', prefix: 'prefix_'); + $blueprint->dropUnique(['foo', 'bar']); + $commands = $blueprint->getCommands(); + $this->assertSame('prefix_users_foo_bar_unique', $commands[0]->index); + + $blueprint = $this->getBlueprint(table: 'users', prefix: 'prefix_'); + $blueprint->dropIndex(['foo']); + $commands = $blueprint->getCommands(); + $this->assertSame('prefix_users_foo_index', $commands[0]->index); + + $blueprint = $this->getBlueprint(table: 'geo', prefix: 'prefix_'); + $blueprint->dropSpatialIndex(['coordinates']); + $commands = $blueprint->getCommands(); + $this->assertSame('prefix_geo_coordinates_spatialindex', $commands[0]->index); + } + + public function testDefaultCurrentDate() + { + $getSql = function ($grammar, $mysql57 = false) { + if ($grammar == 'MySql') { + $connection = $this->getConnection($grammar); + $mysql57 ? $connection->shouldReceive('getServerVersion')->andReturn('5.7') : $connection->shouldReceive('getServerVersion')->andReturn('8.0.13'); + $connection->shouldReceive('isMaria')->andReturn(false); + + return (new Blueprint($connection, 'users', function ($table) { + $table->date('created')->useCurrent(); + }))->toSql(); + } else { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->date('created')->useCurrent(); + })->toSql(); + } + }; + + $this->assertEquals(['alter table `users` add `created` date not null default (CURDATE())'], $getSql('MySql')); + $this->assertEquals(['alter table `users` add `created` date not null'], $getSql('MySql', mysql57: true)); + $this->assertEquals(['alter table "users" add column "created" date not null default CURRENT_DATE'], $getSql('Postgres')); + $this->assertEquals(['alter table "users" add column "created" date not null default CURRENT_DATE'], $getSql('SQLite')); + $this->assertEquals(['alter table "users" add "created" date not null default CAST(GETDATE() AS DATE)'], $getSql('SqlServer')); + } + + public function testDefaultCurrentDateTime() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->dateTime('created')->useCurrent(); + })->toSql(); + }; + + $this->assertEquals(['alter table `users` add `created` datetime not null default CURRENT_TIMESTAMP'], $getSql('MySql')); + $this->assertEquals(['alter table "users" add column "created" timestamp(0) without time zone not null default CURRENT_TIMESTAMP'], $getSql('Postgres')); + $this->assertEquals(['alter table "users" add column "created" datetime not null default CURRENT_TIMESTAMP'], $getSql('SQLite')); + $this->assertEquals(['alter table "users" add "created" datetime not null default CURRENT_TIMESTAMP'], $getSql('SqlServer')); + } + + public function testDefaultCurrentTimestamp() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->timestamp('created')->useCurrent(); + })->toSql(); + }; + + $this->assertEquals(['alter table `users` add `created` timestamp not null default CURRENT_TIMESTAMP'], $getSql('MySql')); + $this->assertEquals(['alter table "users" add column "created" timestamp(0) without time zone not null default CURRENT_TIMESTAMP'], $getSql('Postgres')); + $this->assertEquals(['alter table "users" add column "created" datetime not null default CURRENT_TIMESTAMP'], $getSql('SQLite')); + $this->assertEquals(['alter table "users" add "created" datetime not null default CURRENT_TIMESTAMP'], $getSql('SqlServer')); + } + + public function testDefaultCurrentYear() + { + $getSql = function ($grammar, $mysql57 = false) { + if ($grammar == 'MySql') { + $connection = $this->getConnection($grammar); + $mysql57 ? $connection->shouldReceive('getServerVersion')->andReturn('5.7') : $connection->shouldReceive('getServerVersion')->andReturn('8.0.13'); + $connection->shouldReceive('isMaria')->andReturn(false); + + return (new Blueprint($connection, 'users', function ($table) { + $table->year('birth_year')->useCurrent(); + }))->toSql(); + } else { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->year('birth_year')->useCurrent(); + })->toSql(); + } + }; + + $this->assertEquals(['alter table `users` add `birth_year` year not null default (YEAR(CURDATE()))'], $getSql('MySql')); + $this->assertEquals(['alter table `users` add `birth_year` year not null'], $getSql('MySql', mysql57: true)); + $this->assertEquals(['alter table "users" add column "birth_year" integer not null default EXTRACT(YEAR FROM CURRENT_DATE)'], $getSql('Postgres')); + $this->assertEquals(['alter table "users" add column "birth_year" integer not null default (CAST(strftime(\'%Y\', \'now\') AS INTEGER))'], $getSql('SQLite')); + $this->assertEquals(['alter table "users" add "birth_year" int not null default CAST(YEAR(GETDATE()) AS INTEGER)'], $getSql('SqlServer')); + } + + public function testRemoveColumn() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->string('foo'); + $table->string('remove_this'); + $table->removeColumn('remove_this'); + })->toSql(); + }; + + $this->assertEquals(['alter table `users` add `foo` varchar(255) not null'], $getSql('MySql')); + } + + public function testRenameColumn() + { + $getSql = function ($grammar) { + $connection = $this->getConnection($grammar); + $connection->shouldReceive('getServerVersion')->andReturn('8.0.4'); + $connection->shouldReceive('isMaria')->andReturn(false); + + return (new Blueprint($connection, 'users', function ($table) { + $table->renameColumn('foo', 'bar'); + }))->toSql(); + }; + + $this->assertEquals(['alter table `users` rename column `foo` to `bar`'], $getSql('MySql')); + $this->assertEquals(['alter table "users" rename column "foo" to "bar"'], $getSql('Postgres')); + $this->assertEquals(['alter table "users" rename column "foo" to "bar"'], $getSql('SQLite')); + $this->assertEquals(['sp_rename N\'"users"."foo"\', "bar", N\'COLUMN\''], $getSql('SqlServer')); + } + + public function testNativeRenameColumnOnMysql57() + { + $connection = $this->getConnection('MySql'); + $connection->shouldReceive('isMaria')->andReturn(false); + $connection->shouldReceive('getServerVersion')->andReturn('5.7'); + $connection->getSchemaBuilder()->shouldReceive('getColumns')->andReturn([ + ['name' => 'name', 'type' => 'varchar(255)', 'type_name' => 'varchar', 'nullable' => true, 'collation' => 'utf8mb4_unicode_ci', 'default' => 'foo', 'comment' => null, 'auto_increment' => false, 'generation' => null], + ['name' => 'id', 'type' => 'bigint unsigned', 'type_name' => 'bigint', 'nullable' => false, 'collation' => null, 'default' => null, 'comment' => 'lorem ipsum', 'auto_increment' => true, 'generation' => null], + ['name' => 'generated', 'type' => 'int', 'type_name' => 'int', 'nullable' => false, 'collation' => null, 'default' => null, 'comment' => null, 'auto_increment' => false, 'generation' => ['type' => 'stored', 'expression' => 'expression']], + ]); + + $blueprint = new Blueprint($connection, 'users', function ($table) { + $table->renameColumn('name', 'title'); + $table->renameColumn('id', 'key'); + $table->renameColumn('generated', 'new_generated'); + }); + + $this->assertEquals([ + "alter table `users` change `name` `title` varchar(255) collate 'utf8mb4_unicode_ci' null default 'foo'", + "alter table `users` change `id` `key` bigint unsigned not null auto_increment comment 'lorem ipsum'", + 'alter table `users` change `generated` `new_generated` int as (expression) stored not null', + ], $blueprint->toSql()); + } + + public function testNativeRenameColumnOnLegacyMariaDB() + { + $connection = $this->getConnection('MariaDb'); + $connection->shouldReceive('isMaria')->andReturn(true); + $connection->shouldReceive('getServerVersion')->andReturn('10.1.35'); + $connection->getSchemaBuilder()->shouldReceive('getColumns')->andReturn([ + ['name' => 'name', 'type' => 'varchar(255)', 'type_name' => 'varchar', 'nullable' => true, 'collation' => 'utf8mb4_unicode_ci', 'default' => 'foo', 'comment' => null, 'auto_increment' => false, 'generation' => null], + ['name' => 'id', 'type' => 'bigint unsigned', 'type_name' => 'bigint', 'nullable' => false, 'collation' => null, 'default' => null, 'comment' => 'lorem ipsum', 'auto_increment' => true, 'generation' => null], + ['name' => 'generated', 'type' => 'int', 'type_name' => 'int', 'nullable' => false, 'collation' => null, 'default' => null, 'comment' => null, 'auto_increment' => false, 'generation' => ['type' => 'stored', 'expression' => 'expression']], + ['name' => 'foo', 'type' => 'int', 'type_name' => 'int', 'nullable' => true, 'collation' => null, 'default' => 'NULL', 'comment' => null, 'auto_increment' => false, 'generation' => null], + ]); + + $blueprint = new Blueprint($connection, 'users', function ($table) { + $table->renameColumn('name', 'title'); + $table->renameColumn('id', 'key'); + $table->renameColumn('generated', 'new_generated'); + $table->renameColumn('foo', 'bar'); + }); + + $this->assertEquals([ + "alter table `users` change `name` `title` varchar(255) collate 'utf8mb4_unicode_ci' null default 'foo'", + "alter table `users` change `id` `key` bigint unsigned not null auto_increment comment 'lorem ipsum'", + 'alter table `users` change `generated` `new_generated` int as (expression) stored not null', + 'alter table `users` change `foo` `bar` int null default NULL', + ], $blueprint->toSql()); + } + + public function testDropColumn() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->dropColumn('foo'); + })->toSql(); + }; + + $this->assertEquals(['alter table `users` drop `foo`'], $getSql('MySql')); + $this->assertEquals(['alter table "users" drop column "foo"'], $getSql('Postgres')); + $this->assertEquals(['alter table "users" drop column "foo"'], $getSql('SQLite')); + $this->assertStringContainsString('alter table "users" drop column "foo"', $getSql('SqlServer')[0]); + } + + public function testNativeColumnModifyingOnMySql() + { + $blueprint = $this->getBlueprint('MySql', 'users', function ($table) { + $table->double('amount')->nullable()->invisible()->after('name')->change(); + $table->timestamp('added_at', 4)->nullable(false)->useCurrent()->useCurrentOnUpdate()->change(); + $table->enum('difficulty', ['easy', 'hard'])->default('easy')->charset('utf8mb4')->collation('unicode')->change(); + $table->geometry('positions', 'multipolygon', 1234)->storedAs('expression')->change(); + $table->string('old_name', 50)->renameTo('new_name')->change(); + $table->bigIncrements('id')->first()->from(10)->comment('my comment')->change(); + }); + + $this->assertEquals([ + 'alter table `users` modify `amount` double null invisible after `name`', + 'alter table `users` modify `added_at` timestamp(4) not null default CURRENT_TIMESTAMP(4) on update CURRENT_TIMESTAMP(4)', + "alter table `users` modify `difficulty` enum('easy', 'hard') character set utf8mb4 collate 'unicode' not null default 'easy'", + 'alter table `users` modify `positions` multipolygon srid 1234 as (expression) stored', + 'alter table `users` change `old_name` `new_name` varchar(50) not null', + "alter table `users` modify `id` bigint unsigned not null auto_increment comment 'my comment' first", + 'alter table `users` auto_increment = 10', + ], $blueprint->toSql()); + } + + public function testMacroable() + { + Blueprint::macro('foo', function () { + return $this->addCommand('foo'); + }); + + MySqlGrammar::macro('compileFoo', function () { + return 'bar'; + }); + + $blueprint = $this->getBlueprint('MySql', 'users', function ($table) { + $table->foo(); + }); + + $this->assertEquals(['bar'], $blueprint->toSql()); + } + + public function testDefaultUsingIdMorph() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'comments', function ($table) { + $table->morphs('commentable'); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) not null', + 'alter table `comments` add `commentable_id` bigint unsigned not null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $getSql('MySql')); + } + + public function testDefaultUsingNullableIdMorph() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'comments', function ($table) { + $table->nullableMorphs('commentable'); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) null', + 'alter table `comments` add `commentable_id` bigint unsigned null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $getSql('MySql')); + } + + public function testDefaultUsingUuidMorph() + { + Builder::defaultMorphKeyType('uuid'); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'comments', function ($table) { + $table->morphs('commentable'); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) not null', + 'alter table `comments` add `commentable_id` char(36) not null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $getSql('MySql')); + } + + public function testDefaultUsingNullableUuidMorph() + { + Builder::defaultMorphKeyType('uuid'); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'comments', function ($table) { + $table->nullableMorphs('commentable'); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) null', + 'alter table `comments` add `commentable_id` char(36) null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $getSql('MySql')); + } + + public function testDefaultUsingUlidMorph() + { + Builder::defaultMorphKeyType('ulid'); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'comments', function ($table) { + $table->morphs('commentable'); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) not null', + 'alter table `comments` add `commentable_id` char(26) not null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $getSql('MySql')); + } + + public function testDefaultUsingNullableUlidMorph() + { + Builder::defaultMorphKeyType('ulid'); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'comments', function ($table) { + $table->nullableMorphs('commentable'); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) null', + 'alter table `comments` add `commentable_id` char(26) null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $getSql('MySql')); + } + + public function testGenerateRelationshipColumnWithIncrementalModel() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->foreignIdFor('Illuminate\Foundation\Auth\User'); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` add `user_id` bigint unsigned not null', + ], $getSql('MySql')); + } + + public function testGenerateRelationshipColumnWithNonIncrementalModel() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->foreignIdFor(Fixtures\Models\EloquentModelUsingNonIncrementedInt::class); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` add `model_using_non_incremented_int_id` bigint unsigned not null', + ], $getSql('MySql')); + } + + public function testGenerateRelationshipColumnWithUuidModel() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->foreignIdFor(Fixtures\Models\EloquentModelUsingUuid::class); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` add `model_using_uuid_id` char(36) not null', + ], $getSql('MySql')); + } + + public function testGenerateRelationshipColumnWithUlidModel() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->foreignIdFor(Fixtures\Models\EloquentModelUsingUlid::class); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table "posts" add column "model_using_ulid_id" char(26) not null', + ], $getSql('Postgres')); + + $this->assertEquals([ + 'alter table `posts` add `model_using_ulid_id` char(26) not null', + ], $getSql('MySql')); + } + + public function testGenerateRelationshipConstrainedColumn() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->foreignIdFor('Illuminate\Foundation\Auth\User')->constrained(); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` add `user_id` bigint unsigned not null', + 'alter table `posts` add constraint `posts_user_id_foreign` foreign key (`user_id`) references `users` (`id`)', + ], $getSql('MySql')); + } + + public function testGenerateRelationshipForModelWithNonStandardPrimaryKeyName() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->foreignIdFor(User::class)->constrained(); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` add `user_internal_id` bigint unsigned not null', + 'alter table `posts` add constraint `posts_user_internal_id_foreign` foreign key (`user_internal_id`) references `users` (`internal_id`)', + ], $getSql('MySql')); + } + + public function testDropRelationshipColumnWithIncrementalModel() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->dropForeignIdFor('Illuminate\Foundation\Auth\User'); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` drop `user_id`', + ], $getSql('MySql')); + } + + public function testDropRelationshipColumnWithUuidModel() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->dropForeignIdFor(Fixtures\Models\EloquentModelUsingUuid::class); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` drop `model_using_uuid_id`', + ], $getSql('MySql')); + } + + public function testDropConstrainedRelationshipColumnWithIncrementalModel() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->dropConstrainedForeignIdFor('Illuminate\Foundation\Auth\User'); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` drop foreign key `posts_user_id_foreign`', + 'alter table `posts` drop `user_id`', + ], $getSql('MySql')); + } + + public function testDropConstrainedRelationshipColumnWithUuidModel() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->dropConstrainedForeignIdFor(Fixtures\Models\EloquentModelUsingUuid::class); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` drop foreign key `posts_model_using_uuid_id_foreign`', + 'alter table `posts` drop `model_using_uuid_id`', + ], $getSql('MySql')); + } + + public function testTinyTextColumn() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->tinyText('note'); + })->toSql(); + }; + + $this->assertEquals(['alter table `posts` add `note` tinytext not null'], $getSql('MySql')); + $this->assertEquals(['alter table "posts" add column "note" text not null'], $getSql('SQLite')); + $this->assertEquals(['alter table "posts" add column "note" varchar(255) not null'], $getSql('Postgres')); + $this->assertEquals(['alter table "posts" add "note" nvarchar(255) not null'], $getSql('SqlServer')); + } + + public function testTinyTextNullableColumn() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->tinyText('note')->nullable(); + })->toSql(); + }; + + $this->assertEquals(['alter table `posts` add `note` tinytext null'], $getSql('MySql')); + $this->assertEquals(['alter table "posts" add column "note" text'], $getSql('SQLite')); + $this->assertEquals(['alter table "posts" add column "note" varchar(255) null'], $getSql('Postgres')); + $this->assertEquals(['alter table "posts" add "note" nvarchar(255) null'], $getSql('SqlServer')); + } + + public function testRawColumn() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->rawColumn('legacy_boolean', 'INT(1)')->nullable(); + })->toSql(); + }; + + $this->assertEquals([ + 'alter table `posts` add `legacy_boolean` INT(1) null', + ], $getSql('MySql')); + + $this->assertEquals([ + 'alter table "posts" add column "legacy_boolean" INT(1)', + ], $getSql('SQLite')); + + $this->assertEquals([ + 'alter table "posts" add column "legacy_boolean" INT(1) null', + ], $getSql('Postgres')); + + $this->assertEquals([ + 'alter table "posts" add "legacy_boolean" INT(1) null', + ], $getSql('SqlServer')); + } + + public function testTableComment() + { + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->comment('Look at my comment, it is amazing'); + })->toSql(); + }; + + $this->assertEquals(['alter table `posts` comment = \'Look at my comment, it is amazing\''], $getSql('MySql')); + $this->assertEquals(['comment on table "posts" is \'Look at my comment, it is amazing\''], $getSql('Postgres')); + } + + public function testColumnDefault() + { + // Test a normal string literal column default. + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->tinyText('note')->default('this will work'); + })->toSql(); + }; + + $this->assertEquals(['alter table `posts` add `note` tinytext not null default \'this will work\''], $getSql('MySql')); + + // Test a string literal column default containing an apostrophe (#56124) + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $table->tinyText('note')->default('this\'ll work too'); + })->toSql(); + }; + + $this->assertEquals(['alter table `posts` add `note` tinytext not null default \'this\'\'ll work too\''], $getSql('MySql')); + + // Test a backed enumeration column default + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $enum = ApostropheBackedEnum::ValueWithoutApostrophe; + $table->tinyText('note')->default($enum); + })->toSql(); + }; + $this->assertEquals(['alter table `posts` add `note` tinytext not null default \'this will work\''], $getSql('MySql')); + + // Test a backed enumeration column default containing an apostrophe (#56124) + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'posts', function ($table) { + $enum = ApostropheBackedEnum::ValueWithApostrophe; + $table->tinyText('note')->default($enum); + })->toSql(); + }; + $this->assertEquals(['alter table `posts` add `note` tinytext not null default \'this\'\'ll work too\''], $getSql('MySql')); + } + + protected function getConnection(?string $grammar = null, string $prefix = '') + { + $connection = m::mock(Connection::class) + ->shouldReceive('getTablePrefix')->andReturn($prefix) + ->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true) + ->getMock(); + + $grammar ??= 'MySql'; + $grammarClass = 'Illuminate\Database\Schema\Grammars\\'.$grammar.'Grammar'; + $builderClass = 'Illuminate\Database\Schema\\'.$grammar.'Builder'; + + $connection->shouldReceive('getSchemaGrammar')->andReturn(new $grammarClass($connection)); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock($builderClass)); + + if ($grammar === 'SQLite') { + $connection->shouldReceive('getServerVersion')->andReturn('3.35'); + } + + if ($grammar === 'MySql') { + $connection->shouldReceive('isMaria')->andReturn(false); + } + + return $connection; + } + + protected function getBlueprint( + ?string $grammar = null, + string $table = '', + ?Closure $callback = null, + string $prefix = '' + ): Blueprint { + $connection = $this->getConnection($grammar, $prefix); + + return new Blueprint($connection, $table, $callback); + } +} + +enum ApostropheBackedEnum: string +{ + case ValueWithoutApostrophe = 'this will work'; + case ValueWithApostrophe = 'this\'ll work too'; +} diff --git a/tests/Database/Laravel/DatabaseSchemaBuilderIntegrationTest.php b/tests/Database/Laravel/DatabaseSchemaBuilderIntegrationTest.php new file mode 100644 index 000000000..ff8124f9b --- /dev/null +++ b/tests/Database/Laravel/DatabaseSchemaBuilderIntegrationTest.php @@ -0,0 +1,117 @@ +db = $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->setAsGlobal(); + + $container = new Container; + $container->instance('db', $db->getDatabaseManager()); + Facade::setFacadeApplication($container); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testHasColumnWithTablePrefix() + { + $this->db->connection()->setTablePrefix('test_'); + + $this->db->connection()->getSchemaBuilder()->create('table1', function (Blueprint $table) { + $table->integer('id'); + $table->string('name'); + }); + + $this->assertTrue($this->db->connection()->getSchemaBuilder()->hasColumn('table1', 'name')); + } + + public function testHasColumnAndIndexWithPrefixIndexDisabled() + { + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => 'example_', + 'prefix_indexes' => false, + ]); + + $this->schemaBuilder()->create('table1', function (Blueprint $table) { + $table->integer('id'); + $table->string('name')->index(); + }); + + $this->assertTrue($this->schemaBuilder()->hasIndex('table1', 'table1_name_index')); + } + + public function testHasColumnAndIndexWithPrefixIndexEnabled() + { + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => 'example_', + 'prefix_indexes' => true, + ]); + + $this->schemaBuilder()->create('table1', function (Blueprint $table) { + $table->integer('id'); + $table->string('name')->index(); + }); + + $this->assertTrue($this->schemaBuilder()->hasIndex('table1', 'example_table1_name_index')); + } + + public function testDropColumnWithTablePrefix() + { + $this->db->connection()->setTablePrefix('test_'); + + $this->schemaBuilder()->create('pandemic_table', function (Blueprint $table) { + $table->integer('id'); + $table->string('stay_home'); + $table->string('covid19'); + $table->string('wear_mask'); + }); + + // drop single columns + $this->assertTrue($this->schemaBuilder()->hasColumn('pandemic_table', 'stay_home')); + $this->schemaBuilder()->dropColumns('pandemic_table', 'stay_home'); + $this->assertFalse($this->schemaBuilder()->hasColumn('pandemic_table', 'stay_home')); + + // drop multiple columns + $this->assertTrue($this->schemaBuilder()->hasColumn('pandemic_table', 'covid19')); + $this->schemaBuilder()->dropColumns('pandemic_table', ['covid19', 'wear_mask']); + $this->assertFalse($this->schemaBuilder()->hasColumn('pandemic_table', 'wear_mask')); + $this->assertFalse($this->schemaBuilder()->hasColumn('pandemic_table', 'covid19')); + } + + private function schemaBuilder() + { + return $this->db->connection()->getSchemaBuilder(); + } +} diff --git a/tests/Database/Laravel/DatabaseSchemaBuilderTest.php b/tests/Database/Laravel/DatabaseSchemaBuilderTest.php new file mode 100644 index 000000000..dfbcd9668 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSchemaBuilderTest.php @@ -0,0 +1,83 @@ +shouldReceive('compileCreateDatabase')->andReturn('sql'); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('statement')->with('sql')->andReturnTrue(); + $builder = new Builder($connection); + + $this->assertTrue($builder->createDatabase('foo')); + } + + public function testDropDatabaseIfExists() + { + $connection = m::mock(Connection::class); + $grammar = m::mock(stdClass::class); + $grammar->shouldReceive('compileDropDatabaseIfExists')->andReturn('sql'); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('statement')->with('sql')->andReturnTrue(); + $builder = new Builder($connection); + + $this->assertTrue($builder->dropDatabaseIfExists('foo')); + } + + public function testHasTableCorrectlyCallsGrammar() + { + $connection = m::mock(Connection::class); + $grammar = m::mock(Grammar::class); + $processor = m::mock(Processor::class); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $builder = new Builder($connection); + $grammar->shouldReceive('compileTableExists'); + $grammar->shouldReceive('compileTables')->once()->andReturn('sql'); + $processor->shouldReceive('processTables')->once()->andReturn([['name' => 'prefix_table']]); + $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('sql')->andReturn([['name' => 'prefix_table']]); + + $this->assertTrue($builder->hasTable('table')); + } + + public function testTableHasColumns() + { + $connection = m::mock(Connection::class); + $grammar = m::mock(stdClass::class); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $builder = m::mock(Builder::class.'[getColumnListing]', [$connection]); + $builder->shouldReceive('getColumnListing')->with('users')->twice()->andReturn(['id', 'firstname']); + + $this->assertTrue($builder->hasColumns('users', ['id', 'firstname'])); + $this->assertFalse($builder->hasColumns('users', ['id', 'address'])); + } + + public function testGetColumnTypeAddsPrefix() + { + $connection = m::mock(Connection::class); + $grammar = m::mock(Grammar::class); + $processor = m::mock(Processor::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('getPostProcessor')->andReturn($processor); + $processor->shouldReceive('processColumns')->once()->andReturn([['name' => 'id', 'type_name' => 'integer']]); + $builder = new Builder($connection); + $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); + $grammar->shouldReceive('compileColumns')->once()->with(null, 'prefix_users')->andReturn('sql'); + $connection->shouldReceive('selectFromWriteConnection')->once()->with('sql')->andReturn([['name' => 'id', 'type_name' => 'integer']]); + + $this->assertSame('integer', $builder->getColumnType('users', 'id')); + } +} diff --git a/tests/Database/Laravel/DatabaseSeederTest.php b/tests/Database/Laravel/DatabaseSeederTest.php new file mode 100755 index 000000000..6845b1a1d --- /dev/null +++ b/tests/Database/Laravel/DatabaseSeederTest.php @@ -0,0 +1,87 @@ +setContainer($container = m::mock(Container::class)); + $output = m::mock(OutputInterface::class); + $output->shouldReceive('writeln')->times(3); + $command = m::mock(Command::class); + $command->shouldReceive('getOutput')->times(3)->andReturn($output); + $seeder->setCommand($command); + $container->shouldReceive('make')->once()->with('ClassName')->andReturn($child = m::mock(Seeder::class)); + $child->shouldReceive('setContainer')->once()->with($container)->andReturn($child); + $child->shouldReceive('setCommand')->once()->with($command)->andReturn($child); + $child->shouldReceive('__invoke')->once(); + + $seeder->call('ClassName'); + } + + public function testSetContainer() + { + $seeder = new TestSeeder; + $container = m::mock(Container::class); + $this->assertEquals($seeder->setContainer($container), $seeder); + } + + public function testSetCommand() + { + $seeder = new TestSeeder; + $command = m::mock(Command::class); + $this->assertEquals($seeder->setCommand($command), $seeder); + } + + public function testInjectDependenciesOnRunMethod() + { + $container = m::mock(Container::class); + $container->shouldReceive('call'); + + $seeder = new TestDepsSeeder; + $seeder->setContainer($container); + + $seeder->__invoke(); + + $container->shouldHaveReceived('call')->once()->with([$seeder, 'run'], []); + } + + public function testSendParamsOnCallMethodWithDeps() + { + $container = m::mock(Container::class); + $container->shouldReceive('call'); + + $seeder = new TestDepsSeeder; + $seeder->setContainer($container); + + $seeder->__invoke(['test1', 'test2']); + + $container->shouldHaveReceived('call')->once()->with([$seeder, 'run'], ['test1', 'test2']); + } +} diff --git a/tests/Database/Laravel/DatabaseSoftDeletingScopeTest.php b/tests/Database/Laravel/DatabaseSoftDeletingScopeTest.php new file mode 100644 index 000000000..a6eea9af6 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSoftDeletingScopeTest.php @@ -0,0 +1,155 @@ +shouldReceive('getQualifiedDeletedAtColumn')->once()->andReturn('table.deleted_at'); + $builder->shouldReceive('whereNull')->once()->with('table.deleted_at'); + + $scope->apply($builder, $model); + } + + public function testRestoreExtension() + { + $builder = new EloquentBuilder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + $scope = new SoftDeletingScope; + $scope->extend($builder); + $callback = $builder->getMacro('restore'); + $givenBuilder = m::mock(EloquentBuilder::class); + $givenBuilder->shouldReceive('withTrashed')->once(); + $givenBuilder->shouldReceive('getModel')->once()->andReturn($model = m::mock(stdClass::class)); + $model->shouldReceive('getDeletedAtColumn')->once()->andReturn('deleted_at'); + $givenBuilder->shouldReceive('update')->once()->with(['deleted_at' => null]); + + $callback($givenBuilder); + } + + public function testRestoreOrCreateExtension() + { + $builder = new EloquentBuilder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + + $scope = new SoftDeletingScope; + $scope->extend($builder); + $callback = $builder->getMacro('restoreOrCreate'); + $givenBuilder = m::mock(EloquentBuilder::class); + $givenBuilder->shouldReceive('withTrashed')->once(); + $attributes = ['name' => 'foo']; + $values = ['email' => 'bar']; + $givenBuilder->shouldReceive('firstOrCreate')->once()->with($attributes, $values)->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('restore')->once()->andReturn(true); + $result = $callback($givenBuilder, $attributes, $values); + + $this->assertEquals($model, $result); + } + + public function testCreateOrRestoreExtension() + { + $builder = new EloquentBuilder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + + $scope = new SoftDeletingScope; + $scope->extend($builder); + $callback = $builder->getMacro('createOrRestore'); + $givenBuilder = m::mock(EloquentBuilder::class); + $givenBuilder->shouldReceive('withTrashed')->once(); + $attributes = ['name' => 'foo']; + $values = ['email' => 'bar']; + $givenBuilder->shouldReceive('createOrFirst')->once()->with($attributes, $values)->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('restore')->once()->andReturn(true); + $result = $callback($givenBuilder, $attributes, $values); + + $this->assertEquals($model, $result); + } + + public function testWithTrashedExtension() + { + $builder = new EloquentBuilder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + $scope = m::mock(SoftDeletingScope::class.'[remove]'); + $scope->extend($builder); + $callback = $builder->getMacro('withTrashed'); + $givenBuilder = m::mock(EloquentBuilder::class); + $givenBuilder->shouldReceive('getModel')->andReturn($model = m::mock(Model::class)); + $givenBuilder->shouldReceive('withoutGlobalScope')->with($scope)->andReturn($givenBuilder); + $result = $callback($givenBuilder); + + $this->assertEquals($givenBuilder, $result); + } + + public function testOnlyTrashedExtension() + { + $builder = new EloquentBuilder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + $model = m::mock(Model::class); + $model->makePartial(); + $scope = m::mock(SoftDeletingScope::class.'[remove]'); + $scope->extend($builder); + $callback = $builder->getMacro('onlyTrashed'); + $givenBuilder = m::mock(EloquentBuilder::class); + $givenBuilder->shouldReceive('getQuery')->andReturn($query = m::mock(stdClass::class)); + $givenBuilder->shouldReceive('getModel')->andReturn($model); + $givenBuilder->shouldReceive('withoutGlobalScope')->with($scope)->andReturn($givenBuilder); + $model->shouldReceive('getQualifiedDeletedAtColumn')->andReturn('table.deleted_at'); + $givenBuilder->shouldReceive('whereNotNull')->once()->with('table.deleted_at'); + $result = $callback($givenBuilder); + + $this->assertEquals($givenBuilder, $result); + } + + public function testWithoutTrashedExtension() + { + $builder = new EloquentBuilder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + $model = m::mock(Model::class); + $model->makePartial(); + $scope = m::mock(SoftDeletingScope::class.'[remove]'); + $scope->extend($builder); + $callback = $builder->getMacro('withoutTrashed'); + $givenBuilder = m::mock(EloquentBuilder::class); + $givenBuilder->shouldReceive('getQuery')->andReturn($query = m::mock(stdClass::class)); + $givenBuilder->shouldReceive('getModel')->andReturn($model); + $givenBuilder->shouldReceive('withoutGlobalScope')->with($scope)->andReturn($givenBuilder); + $model->shouldReceive('getQualifiedDeletedAtColumn')->andReturn('table.deleted_at'); + $givenBuilder->shouldReceive('whereNull')->once()->with('table.deleted_at'); + $result = $callback($givenBuilder); + + $this->assertEquals($givenBuilder, $result); + } +} diff --git a/tests/Database/Laravel/DatabaseSoftDeletingTest.php b/tests/Database/Laravel/DatabaseSoftDeletingTest.php new file mode 100644 index 000000000..1303278fe --- /dev/null +++ b/tests/Database/Laravel/DatabaseSoftDeletingTest.php @@ -0,0 +1,70 @@ +assertArrayHasKey('deleted_at', $model->getCasts()); + $this->assertSame('datetime', $model->getCasts()['deleted_at']); + } + + public function testDeletedAtIsCastToCarbonInstance() + { + $expected = Carbon::createFromFormat('Y-m-d H:i:s', '2018-12-29 13:59:39'); + $model = new SoftDeletingModel(['deleted_at' => $expected->format('Y-m-d H:i:s')]); + + $this->assertInstanceOf(Carbon::class, $model->deleted_at); + $this->assertTrue($expected->eq($model->deleted_at)); + } + + public function testExistingCastOverridesAddedDateCast() + { + $model = new class(['deleted_at' => '2018-12-29 13:59:39']) extends SoftDeletingModel + { + protected $casts = ['deleted_at' => 'bool']; + }; + + $this->assertTrue($model->deleted_at); + } + + public function testExistingMutatorOverridesAddedDateCast() + { + $model = new class(['deleted_at' => '2018-12-29 13:59:39']) extends SoftDeletingModel + { + protected function getDeletedAtAttribute() + { + return 'expected'; + } + }; + + $this->assertSame('expected', $model->deleted_at); + } + + public function testCastingToStringOverridesAutomaticDateCastingToRetainPreviousBehaviour() + { + $model = new class(['deleted_at' => '2018-12-29 13:59:39']) extends SoftDeletingModel + { + protected $casts = ['deleted_at' => 'string']; + }; + + $this->assertSame('2018-12-29 13:59:39', $model->deleted_at); + } +} + +class SoftDeletingModel extends Model +{ + use SoftDeletes; + + protected $guarded = []; + + protected $dateFormat = 'Y-m-d H:i:s'; +} diff --git a/tests/Database/Laravel/DatabaseSoftDeletingTraitTest.php b/tests/Database/Laravel/DatabaseSoftDeletingTraitTest.php new file mode 100644 index 000000000..f6823028e --- /dev/null +++ b/tests/Database/Laravel/DatabaseSoftDeletingTraitTest.php @@ -0,0 +1,122 @@ +makePartial(); + $model->shouldReceive('newModelQuery')->andReturn($query = m::mock(stdClass::class)); + $query->shouldReceive('where')->once()->with('id', '=', 1)->andReturn($query); + $query->shouldReceive('update')->once()->with([ + 'deleted_at' => 'date-time', + 'updated_at' => 'date-time', + ]); + $model->shouldReceive('syncOriginalAttributes')->once()->with([ + 'deleted_at', + 'updated_at', + ]); + $model->shouldReceive('usesTimestamps')->once()->andReturn(true); + $model->delete(); + + $this->assertInstanceOf(Carbon::class, $model->deleted_at); + } + + public function testRestore() + { + $model = m::mock(DatabaseSoftDeletingTraitStub::class); + $model->makePartial(); + $model->shouldReceive('fireModelEvent')->with('restoring')->andReturn(true); + $model->shouldReceive('save')->once(); + $model->shouldReceive('fireModelEvent')->with('restored', false)->andReturn(true); + + $model->restore(); + + $this->assertNull($model->deleted_at); + } + + public function testRestoreCancel() + { + $model = m::mock(DatabaseSoftDeletingTraitStub::class); + $model->makePartial(); + $model->shouldReceive('fireModelEvent')->with('restoring')->andReturn(false); + $model->shouldReceive('save')->never(); + + $this->assertFalse($model->restore()); + } +} + +class DatabaseSoftDeletingTraitStub +{ + use SoftDeletes; + + public $deleted_at; + public $updated_at; + public $timestamps = true; + public $exists = false; + + public function newQuery() + { + // + } + + public function getKey() + { + return 1; + } + + public function getKeyName() + { + return 'id'; + } + + public function save() + { + // + } + + public function delete() + { + return $this->performDeleteOnModel(); + } + + public function fireModelEvent() + { + // + } + + public function freshTimestamp() + { + return Carbon::now(); + } + + public function fromDateTime() + { + return 'date-time'; + } + + public function getUpdatedAtColumn() + { + return defined('static::UPDATED_AT') ? static::UPDATED_AT : 'updated_at'; + } + + public function setKeysForSaveQuery($query) + { + $query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery()); + + return $query; + } + + protected function getKeyForSaveQuery() + { + return 1; + } +} diff --git a/tests/Database/Laravel/DatabaseSqliteSchemaStateTest.php b/tests/Database/Laravel/DatabaseSqliteSchemaStateTest.php new file mode 100644 index 000000000..7bda4f246 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSqliteSchemaStateTest.php @@ -0,0 +1,54 @@ + 'sqlite', 'database' => 'database/database.sqlite', 'prefix' => '', 'foreign_key_constraints' => true, 'name' => 'sqlite']; + $connection = m::mock(SQLiteConnection::class); + $connection->shouldReceive('getConfig')->andReturn($config); + $connection->shouldReceive('getDatabaseName')->andReturn($config['database']); + + $process = m::spy(Process::class); + $processFactory = m::spy(function () use ($process) { + return $process; + }); + + $schemaState = new SqliteSchemaState($connection, null, $processFactory); + $schemaState->load('database/schema/sqlite-schema.dump'); + + $processFactory->shouldHaveBeenCalled()->with('sqlite3 "${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"'); + + $process->shouldHaveReceived('mustRun')->with(null, [ + 'LARAVEL_LOAD_DATABASE' => 'database/database.sqlite', + 'LARAVEL_LOAD_PATH' => 'database/schema/sqlite-schema.dump', + ]); + } + + public function testLoadSchemaToInMemory(): void + { + $config = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'foreign_key_constraints' => true, 'name' => 'sqlite']; + $connection = m::mock(SQLiteConnection::class); + $connection->shouldReceive('getConfig')->andReturn($config); + $connection->shouldReceive('getDatabaseName')->andReturn($config['database']); + $connection->shouldReceive('getPdo')->andReturn($pdo = m::spy(PDO::class)); + + $files = m::mock(Filesystem::class); + $files->shouldReceive('get')->andReturn('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer not null primary key autoincrement, "migration" varchar not null, "batch" integer not null);'); + + $schemaState = new SqliteSchemaState($connection, $files); + $schemaState->load('database/schema/sqlite-schema.dump'); + + $pdo->shouldHaveReceived('exec')->with('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer not null primary key autoincrement, "migration" varchar not null, "batch" integer not null);'); + } +} diff --git a/tests/Database/Laravel/DatabaseTransactionsManagerTest.php b/tests/Database/Laravel/DatabaseTransactionsManagerTest.php new file mode 100755 index 000000000..545dc4367 --- /dev/null +++ b/tests/Database/Laravel/DatabaseTransactionsManagerTest.php @@ -0,0 +1,336 @@ +begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $this->assertCount(3, $manager->getPendingTransactions()); + $this->assertSame('default', $manager->getPendingTransactions()[0]->connection); + $this->assertEquals(1, $manager->getPendingTransactions()[0]->level); + $this->assertSame('default', $manager->getPendingTransactions()[1]->connection); + $this->assertEquals(2, $manager->getPendingTransactions()[1]->level); + $this->assertSame('admin', $manager->getPendingTransactions()[2]->connection); + $this->assertEquals(1, $manager->getPendingTransactions()[2]->level); + } + + public function testRollingBackTransactions() + { + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->rollback('default', 1); + + $this->assertCount(2, $manager->getPendingTransactions()); + + $this->assertSame('default', $manager->getPendingTransactions()[0]->connection); + $this->assertEquals(1, $manager->getPendingTransactions()[0]->level); + + $this->assertSame('admin', $manager->getPendingTransactions()[1]->connection); + $this->assertEquals(1, $manager->getPendingTransactions()[1]->level); + } + + public function testRollingBackTransactionsAllTheWay() + { + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->rollback('default', 0); + + $this->assertCount(1, $manager->getPendingTransactions()); + + $this->assertSame('admin', $manager->getPendingTransactions()[0]->connection); + $this->assertEquals(1, $manager->getPendingTransactions()[0]->level); + } + + public function testCommittingTransactions() + { + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + $manager->begin('admin', 2); + + $manager->commit('default', 2, 1); + $executedTransactions = $manager->commit('default', 1, 0); + + $executedAdminTransactions = $manager->commit('admin', 2, 1); + + $this->assertCount(1, $manager->getPendingTransactions()); // One pending "admin" transaction left... + $this->assertCount(2, $executedTransactions); // Two committed transactions on "default" + $this->assertCount(0, $executedAdminTransactions); // Zero executed committed transactions on "default" + + // Level 2 "admin" callback has been staged... + $this->assertSame('admin', $manager->getCommittedTransactions()[0]->connection); + $this->assertEquals(2, $manager->getCommittedTransactions()[0]->level); + + // Level 1 "admin" callback still pending... + $this->assertSame('admin', $manager->getPendingTransactions()[0]->connection); + $this->assertEquals(1, $manager->getPendingTransactions()[0]->level); + } + + public function testCallbacksAreAddedToTheCurrentTransaction() + { + $callbacks = []; + + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$callbacks) { + }); + + $manager->begin('default', 2); + + $manager->begin('admin', 1); + + $manager->addCallback(function () use (&$callbacks) { + }); + + $this->assertCount(1, $manager->getPendingTransactions()[0]->getCallbacks()); + $this->assertCount(0, $manager->getPendingTransactions()[1]->getCallbacks()); + $this->assertCount(1, $manager->getPendingTransactions()[2]->getCallbacks()); + } + + public function testCallbacksRunInFifoOrder() + { + $manager = new DatabaseTransactionsManager; + + $order = []; + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$order) { + $order[] = 1; + }); + + $manager->addCallback(function () use (&$order) { + $order[] = 2; + }); + + $manager->addCallback(function () use (&$order) { + $order[] = 3; + }); + + $manager->commit('default', 1, 0); + + $this->assertSame([1, 2, 3], $order); + } + + public function testCommittingTransactionsExecutesCallbacks() + { + $callbacks = []; + + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $manager->begin('default', 2); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 2]; + }); + + $manager->begin('admin', 1); + + $manager->commit('default', 2, 1); + $manager->commit('default', 1, 0); + + $this->assertCount(2, $callbacks); + $this->assertEquals(['default', 2], $callbacks[0]); + $this->assertEquals(['default', 1], $callbacks[1]); + } + + public function testCommittingExecutesOnlyCallbacksOfTheConnection() + { + $callbacks = []; + + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['admin', 1]; + }); + + $manager->commit('default', 2, 1); + $manager->commit('default', 1, 0); + + $this->assertCount(1, $callbacks); + $this->assertEquals(['default', 1], $callbacks[0]); + } + + public function testCallbackIsExecutedIfNoTransactions() + { + $callbacks = []; + + $manager = new DatabaseTransactionsManager; + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $this->assertCount(1, $callbacks); + $this->assertEquals(['default', 1], $callbacks[0]); + } + + public function testCallbacksForRollbackAreAddedToTheCurrentTransaction() + { + $callbacks = []; + + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + + $manager->addCallbackForRollback(function () use (&$callbacks) { + }); + + $manager->begin('default', 2); + + $manager->begin('admin', 1); + + $manager->addCallbackForRollback(function () use (&$callbacks) { + }); + + $this->assertCount(1, $manager->getPendingTransactions()[0]->getCallbacksForRollback()); + $this->assertCount(0, $manager->getPendingTransactions()[1]->getCallbacksForRollback()); + $this->assertCount(1, $manager->getPendingTransactions()[2]->getCallbacksForRollback()); + } + + public function testRollbackTransactionsExecutesCallbacks() + { + $callbacks = []; + + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + + $manager->addCallbackForRollback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $manager->begin('default', 2); + + $manager->addCallbackForRollback(function () use (&$callbacks) { + $callbacks[] = ['default', 2]; + }); + + $manager->begin('admin', 1); + + $manager->rollback('default', 1); + $manager->rollback('default', 0); + + $this->assertCount(2, $callbacks); + $this->assertEquals(['default', 2], $callbacks[0]); + $this->assertEquals(['default', 1], $callbacks[1]); + } + + public function testRollbackExecutesOnlyCallbacksOfTheConnection() + { + $callbacks = []; + + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + + $manager->addCallbackForRollback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->addCallbackForRollback(function () use (&$callbacks) { + $callbacks[] = ['admin', 1]; + }); + + $manager->rollback('default', 1); + $manager->rollback('default', 0); + + $this->assertCount(1, $callbacks); + $this->assertEquals(['default', 1], $callbacks[0]); + } + + public function testCallbackForRollbackIsNotExecutedIfNoTransactions() + { + $callbacks = []; + + $manager = new DatabaseTransactionsManager; + + $manager->addCallbackForRollback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $this->assertCount(0, $callbacks); + } + + public function testStageTransactions() + { + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + $manager->begin('admin', 1); + + $this->assertCount(2, $manager->getPendingTransactions()); + + $pendingTransactions = $manager->getPendingTransactions(); + + $this->assertEquals(1, $pendingTransactions[0]->level); + $this->assertEquals('default', $pendingTransactions[0]->connection); + $this->assertEquals(1, $pendingTransactions[1]->level); + $this->assertEquals('admin', $pendingTransactions[1]->connection); + + $manager->stageTransactions('default', 1); + + $this->assertCount(1, $manager->getPendingTransactions()); + $this->assertCount(1, $manager->getCommittedTransactions()); + $this->assertEquals('default', $manager->getCommittedTransactions()[0]->connection); + + $manager->stageTransactions('admin', 1); + + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(2, $manager->getCommittedTransactions()); + $this->assertEquals('admin', $manager->getCommittedTransactions()[1]->connection); + } + + public function testStageTransactionsOnlyStagesTheTransactionsAtOrAboveTheGivenLevel() + { + $manager = new DatabaseTransactionsManager; + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('default', 3); + $manager->stageTransactions('default', 2); + + $this->assertCount(1, $manager->getPendingTransactions()); + $this->assertCount(2, $manager->getCommittedTransactions()); + } +} diff --git a/tests/Database/Laravel/DatabaseTransactionsTest.php b/tests/Database/Laravel/DatabaseTransactionsTest.php new file mode 100644 index 000000000..f0805ab89 --- /dev/null +++ b/tests/Database/Laravel/DatabaseTransactionsTest.php @@ -0,0 +1,258 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'second_connection'); + + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + foreach (['default', 'second_connection'] as $connection) { + $this->schema($connection)->create('users', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('value')->nullable(); + }); + } + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + foreach (['default', 'second_connection'] as $connection) { + $this->schema($connection)->drop('users'); + } + + parent::tearDown(); + } + + public function testTransactionIsRecordedAndCommitted() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('commit')->once()->with('default', 1, 0); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + }); + } + + public function testTransactionIsRecordedAndCommittedUsingTheSeparateMethods() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('commit')->once()->with('default', 1, 0); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->beginTransaction(); + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + $this->connection()->commit(); + } + + public function testNestedTransactionIsRecordedAndCommitted() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('begin')->once()->with('default', 2); + $transactionManager->shouldReceive('commit')->once()->with('default', 2, 1); + $transactionManager->shouldReceive('commit')->once()->with('default', 1, 0); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + }); + }); + } + + public function testNestedTransactionIsRecordeForDifferentConnectionsdAndCommitted() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('begin')->once()->with('second_connection', 1); + $transactionManager->shouldReceive('begin')->once()->with('second_connection', 2); + $transactionManager->shouldReceive('commit')->once()->with('default', 1, 0); + $transactionManager->shouldReceive('commit')->once()->with('second_connection', 2, 1); + $transactionManager->shouldReceive('commit')->once()->with('second_connection', 1, 0); + + $this->connection()->setTransactionManager($transactionManager); + $this->connection('second_connection')->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection('second_connection')->transaction(function () { + $this->connection('second_connection')->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection('second_connection')->transaction(function () { + $this->connection('second_connection')->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + }); + }); + }); + } + + public function testTransactionIsRolledBack() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('rollback')->once()->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + try { + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + throw new Exception; + }); + } catch (Throwable) { + } + } + + public function testTransactionIsRolledBackUsingSeparateMethods() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('rollback')->once()->with('default', 0); + $transactionManager->shouldNotReceive('commit', 1, 0); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->beginTransaction(); + + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->rollBack(); + } + + public function testNestedTransactionsAreRolledBack() + { + $transactionManager = m::mock(new DatabaseTransactionsManager); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('begin')->once()->with('default', 2); + $transactionManager->shouldReceive('rollback')->once()->with('default', 1); + $transactionManager->shouldReceive('rollback')->once()->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + try { + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + throw new Exception; + }); + }); + } catch (Throwable) { + } + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } + + public function connection($name = 'default') + { + return DB::connection($name); + } +} diff --git a/tests/Database/Laravel/EloquentHasOneOrManyDeprecationTest.php b/tests/Database/Laravel/EloquentHasOneOrManyDeprecationTest.php new file mode 100644 index 000000000..d02af56ba --- /dev/null +++ b/tests/Database/Laravel/EloquentHasOneOrManyDeprecationTest.php @@ -0,0 +1,95 @@ +getHasManyRelation(); + + $result1 = new HasOneOrManyDeprecationModelStub; + $result1->foreign_key = 1; + + $result2 = new HasOneOrManyDeprecationModelStub; + $result2->foreign_key = ''; + + $model1 = new HasOneOrManyDeprecationModelStub; + $model1->id = 1; + $model2 = new HasOneOrManyDeprecationModelStub; + $model2->id = null; + + $relation->getRelated()->shouldReceive('newCollection')->andReturnUsing(function ($array) { + return new Collection($array); + }); + + $models = $relation->match([$model1, $model2], new Collection([$result1, $result2]), 'foo'); + + $this->assertCount(1, $models[0]->foo); + $this->assertNull($models[1]->foo); + } + + public function testHasOneMatchWithNullLocalKey(): void + { + $relation = $this->getHasOneRelation(); + + $result1 = new HasOneOrManyDeprecationModelStub; + $result1->foreign_key = 1; + + $model1 = new HasOneOrManyDeprecationModelStub; + $model1->id = 1; + $model2 = new HasOneOrManyDeprecationModelStub; + $model2->id = null; + + $models = $relation->match([$model1, $model2], new Collection([$result1]), 'foo'); + + $this->assertInstanceOf(HasOneOrManyDeprecationModelStub::class, $models[0]->foo); + $this->assertNull($models[1]->foo); + } + + protected function getHasManyRelation(): HasMany + { + $queryBuilder = m::mock(QueryBuilder::class); + $builder = m::mock(Builder::class, [$queryBuilder]); + $builder->shouldReceive('whereNotNull')->with('table.foreign_key'); + $builder->shouldReceive('where')->with('table.foreign_key', '=', 1); + $related = m::mock(Model::class); + $builder->shouldReceive('getModel')->andReturn($related); + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); + $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + + return new HasMany($builder, $parent, 'table.foreign_key', 'id'); + } + + protected function getHasOneRelation(): HasOne + { + $queryBuilder = m::mock(QueryBuilder::class); + $builder = m::mock(Builder::class, [$queryBuilder]); + $builder->shouldReceive('whereNotNull')->with('table.foreign_key'); + $builder->shouldReceive('where')->with('table.foreign_key', '=', 1); + $related = m::mock(Model::class); + $builder->shouldReceive('getModel')->andReturn($related); + $parent = m::mock(Model::class); + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); + $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + + return new HasOne($builder, $parent, 'table.foreign_key', 'id'); + } +} + +class HasOneOrManyDeprecationModelStub extends Model +{ + public $foreign_key; +} diff --git a/tests/Database/Laravel/EloquentModelCustomCastingTest.php b/tests/Database/Laravel/EloquentModelCustomCastingTest.php new file mode 100644 index 000000000..2a0dc55b9 --- /dev/null +++ b/tests/Database/Laravel/EloquentModelCustomCastingTest.php @@ -0,0 +1,523 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('casting_table', function (Blueprint $table) { + $table->increments('id'); + $table->string('address_line_one'); + $table->string('address_line_two'); + $table->integer('amount'); + $table->string('string_field'); + $table->timestamps(); + }); + + $this->schema()->create('members', function (Blueprint $table) { + $table->increments('id'); + $table->decimal('amount', 4, 2); + }); + + $this->schema()->create('documents', function (Blueprint $table) { + $table->increments('id'); + $table->json('document'); + }); + + $this->schema()->create('people', function (Blueprint $table) { + $table->increments('id'); + $table->string('address_line_one'); + $table->string('address_line_two'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('casting_table'); + $this->schema()->drop('members'); + $this->schema()->drop('documents'); + + parent::tearDown(); + } + + #[RequiresPhpExtension('gmp')] + public function testSavingCastedAttributesToDatabase() + { + /** @var \Illuminate\Tests\Integration\Database\CustomCasts $model */ + $model = CustomCasts::create([ + 'address' => new AddressModel('address_line_one_value', 'address_line_two_value'), + 'amount' => gmp_init('1000', 10), + 'string_field' => null, + ]); + + $this->assertSame('address_line_one_value', $model->getOriginal('address_line_one')); + $this->assertSame('address_line_one_value', $model->getAttribute('address_line_one')); + + $this->assertSame('address_line_two_value', $model->getOriginal('address_line_two')); + $this->assertSame('address_line_two_value', $model->getAttribute('address_line_two')); + + $this->assertSame('1000', $model->getRawOriginal('amount')); + + $this->assertNull($model->getOriginal('string_field')); + $this->assertNull($model->getAttribute('string_field')); + $this->assertSame('', $model->getRawOriginal('string_field')); + + /** @var \Illuminate\Tests\Integration\Database\CustomCasts $another_model */ + $another_model = CustomCasts::create([ + 'address_line_one' => 'address_line_one_value', + 'address_line_two' => 'address_line_two_value', + 'amount' => gmp_init('500', 10), + 'string_field' => 'string_value', + ]); + + $this->assertInstanceOf(AddressModel::class, $another_model->address); + + $this->assertSame('address_line_one_value', $model->address->lineOne); + $this->assertSame('address_line_two_value', $model->address->lineTwo); + $this->assertInstanceOf(GMP::class, $model->amount); + } + + #[RequiresPhpExtension('gmp')] + public function testInvalidArgumentExceptionOnInvalidValue() + { + /** @var \Illuminate\Tests\Integration\Database\CustomCasts $model */ + $model = CustomCasts::create([ + 'address' => new AddressModel('address_line_one_value', 'address_line_two_value'), + 'amount' => gmp_init('1000', 10), + 'string_field' => 'string_value', + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The given value is not an Address instance.'); + $model->address = 'single_string'; + + // Ensure model values remain unchanged + $this->assertSame('address_line_one_value', $model->address->lineOne); + $this->assertSame('address_line_two_value', $model->address->lineTwo); + } + + #[RequiresPhpExtension('gmp')] + public function testInvalidArgumentExceptionOnNull() + { + /** @var \Illuminate\Tests\Integration\Database\CustomCasts $model */ + $model = CustomCasts::create([ + 'address' => new AddressModel('address_line_one_value', 'address_line_two_value'), + 'amount' => gmp_init('1000', 10), + 'string_field' => 'string_value', + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The given value is not an Address instance.'); + $model->address = null; + + // Ensure model values remain unchanged + $this->assertSame('address_line_one_value', $model->address->lineOne); + $this->assertSame('address_line_two_value', $model->address->lineTwo); + } + + #[RequiresPhpExtension('gmp')] + public function testModelsWithCustomCastsCanBeConvertedToArrays() + { + /** @var \Illuminate\Tests\Integration\Database\CustomCasts $model */ + $model = CustomCasts::create([ + 'address' => new AddressModel('address_line_one_value', 'address_line_two_value'), + 'amount' => gmp_init('1000', 10), + 'string_field' => 'string_value', + ]); + + // Ensure model values remain unchanged + $this->assertSame([ + 'address_line_one' => 'address_line_one_value', + 'address_line_two' => 'address_line_two_value', + 'amount' => '1000', + 'string_field' => 'string_value', + 'updated_at' => $model->updated_at->toJSON(), + 'created_at' => $model->created_at->toJSON(), + 'id' => 1, + ], $model->toArray()); + } + + public function testModelWithCustomCastsWorkWithCustomIncrementDecrement() + { + $model = new Member(); + $model->amount = new Euro('2'); + $model->save(); + + $this->assertInstanceOf(Euro::class, $model->amount); + $this->assertEquals('2', $model->amount->value); + + $model->increment('amount', new Euro('1')); + $this->assertEquals('3.00', $model->amount->value); + } + + public function testModelWithCustomCastsCompareFunction() + { + // Set raw attribute, this is an example of how we would receive JSON string from the database. + // Note the spaces after the colon. + $model = new Document(); + $model->setRawAttributes(['document' => '{"content": "content", "title": "hello world"}']); + $model->save(); + + // Inverse title and content this would result in a different JSON string when json_encode is used + $document = new \stdClass(); + $document->title = 'hello world'; + $document->content = 'content'; + $model->document = $document; + + $this->assertFalse($model->isDirty('document')); + $document->title = 'hello world 2'; + $this->assertTrue($model->isDirty('document')); + } + + public function testModelWithCustomCastsUnguardedCanBeMassAssigned() + { + Person::preventSilentlyDiscardingAttributes(); + + $model = Person::create(['address' => new AddressDto('123 Main St.', 'Anytown, USA')]); + $this->assertSame('123 Main St.', $model->address->lineOne); + $this->assertSame('Anytown, USA', $model->address->lineTwo); + } + + public function testModelWithCustomCastsCanBeGuardedAgainstMassAssigned() + { + Person::preventSilentlyDiscardingAttributes(); + $this->expectException(MassAssignmentException::class); + + $model = new Person(); + $model->guard(['address']); + $model->create(['id' => 1, 'address' => new AddressDto('123 Main St.', 'Anytown, USA')]); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Casts... + */ +class AddressCast implements CastsAttributes +{ + /** + * Cast the given value. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return \Illuminate\Tests\Integration\Database\AddressModel + */ + public function get($model, $key, $value, $attributes) + { + return new AddressModel( + $attributes['address_line_one'], + $attributes['address_line_two'], + ); + } + + /** + * Prepare the given value for storage. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param AddressModel $value + * @param array $attributes + * @return array + */ + public function set($model, $key, $value, $attributes) + { + if (! $value instanceof AddressModel) { + throw new InvalidArgumentException('The given value is not an Address instance.'); + } + + return [ + 'address_line_one' => $value->lineOne, + 'address_line_two' => $value->lineTwo, + ]; + } +} + +class GMPCast implements CastsAttributes, SerializesCastableAttributes +{ + /** + * Cast the given value. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param string $value + * @param array $attributes + * @return string|null + */ + public function get($model, $key, $value, $attributes) + { + return gmp_init($value, 10); + } + + /** + * Prepare the given value for storage. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param string|null $value + * @param array $attributes + * @return string + */ + public function set($model, $key, $value, $attributes) + { + return gmp_strval($value, 10); + } + + /** + * Serialize the attribute when converting the model to an array. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function serialize($model, string $key, $value, array $attributes) + { + return gmp_strval($value, 10); + } +} + +class NonNullableString implements CastsAttributes +{ + /** + * Cast the given value. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param string $value + * @param array $attributes + * @return string|null + */ + public function get($model, $key, $value, $attributes) + { + return ($value != '') ? $value : null; + } + + /** + * Prepare the given value for storage. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param string|null $value + * @param array $attributes + * @return string + */ + public function set($model, $key, $value, $attributes) + { + return $value ?? ''; + } +} + +/** + * Eloquent Models... + */ +class CustomCasts extends Eloquent +{ + /** + * @var string + */ + protected $table = 'casting_table'; + + /** + * @var string[] + */ + protected $guarded = []; + + /** + * @var array + */ + protected $casts = [ + 'address' => AddressCast::class, + 'amount' => GMPCast::class, + 'string_field' => NonNullableString::class, + ]; +} + +class AddressModel +{ + /** + * @var string + */ + public $lineOne; + + /** + * @var string + */ + public $lineTwo; + + public function __construct($address_line_one, $address_line_two) + { + $this->lineOne = $address_line_one; + $this->lineTwo = $address_line_two; + } +} + +class Euro implements Castable +{ + public string $value; + + public function __construct(string $value) + { + $this->value = $value; + } + + public static function castUsing(array $arguments) + { + return EuroCaster::class; + } +} + +class EuroCaster implements CastsAttributes +{ + public function get($model, $key, $value, $attributes) + { + return new Euro($value); + } + + public function set($model, $key, $value, $attributes) + { + return $value instanceof Euro ? $value->value : $value; + } + + public function increment($model, $key, $value, $attributes) + { + $model->$key = new Euro((string) BigNumber::of($model->$key->value)->plus($value->value)->toScale(2)); + + return $model->$key; + } + + public function decrement($model, $key, $value, $attributes) + { + $model->$key = new Euro((string) BigNumber::of($model->$key->value)->subtract($value->value)->toScale(2)); + + return $model->$key; + } +} + +class Member extends Model +{ + public $timestamps = false; + protected $casts = [ + 'amount' => Euro::class, + ]; +} + +class Document extends Model +{ + public $timestamps = false; + + protected $casts = [ + 'document' => StructuredDocumentCaster::class, + ]; +} + +class Person extends Model +{ + protected $guarded = ['id']; + public $timestamps = false; + protected $casts = [ + 'address' => AsAddress::class, + ]; +} + +class StructuredDocumentCaster implements CastsAttributes, ComparesCastableAttributes +{ + public function get($model, $key, $value, $attributes) + { + return json_decode($value); + } + + public function set($model, $key, $value, $attributes) + { + return json_encode($value); + } + + public function compare($model, $key, $value1, $value2) + { + return json_decode($value1) == json_decode($value2); + } +} + +class AddressDto +{ + public function __construct(public string $lineOne, public string $lineTwo) + { + // + } +} + +class AsAddress implements CastsAttributes +{ + public function get($model, $key, $value, $attributes) + { + return new AddressDto($attributes['address_line_one'], $attributes['address_line_two']); + } + + public function set($model, $key, $value, $attributes) + { + return ['address_line_one' => $value->lineOne, 'address_line_two' => $value->lineTwo]; + } +} diff --git a/tests/Database/Laravel/Enums.php b/tests/Database/Laravel/Enums.php new file mode 100644 index 000000000..01a9781ec --- /dev/null +++ b/tests/Database/Laravel/Enums.php @@ -0,0 +1,51 @@ + 'pending status description', + self::done => 'done status description' + }; + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'value' => $this->value, + 'description' => $this->description(), + ]; + } +} diff --git a/tests/Database/Laravel/Fixtures/Enums/Bar.php b/tests/Database/Laravel/Fixtures/Enums/Bar.php new file mode 100644 index 000000000..e1f20f359 --- /dev/null +++ b/tests/Database/Laravel/Fixtures/Enums/Bar.php @@ -0,0 +1,10 @@ + $this->faker->name(), + ]; + } +} diff --git a/tests/Database/Laravel/Fixtures/Models/EloquentModelUsingNonIncrementedInt.php b/tests/Database/Laravel/Fixtures/Models/EloquentModelUsingNonIncrementedInt.php new file mode 100644 index 000000000..0ea21ecee --- /dev/null +++ b/tests/Database/Laravel/Fixtures/Models/EloquentModelUsingNonIncrementedInt.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + protected ?string $table = 'prices'; + + protected static string $factory = PriceFactory::class; +} diff --git a/tests/Database/Laravel/Fixtures/Models/User.php b/tests/Database/Laravel/Fixtures/Models/User.php new file mode 100644 index 000000000..6d1be3721 --- /dev/null +++ b/tests/Database/Laravel/Fixtures/Models/User.php @@ -0,0 +1,12 @@ + $this->namespace = 'Illuminate\\Tests\\Database\\Pruning\\', + $container, + Application::class, + )(); + + $container->useAppPath(__DIR__.'/Pruning'); + + $container->singleton(DispatcherContract::class, function () { + return new Dispatcher(); + }); + + $container->alias(DispatcherContract::class, 'events'); + } + + public function testPrunableModelAndExceptWithEachOther(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The --models and --except options cannot be combined.'); + + $this->artisan([ + '--model' => Pruning\Models\PrunableTestModelWithPrunableRecords::class, + '--except' => Pruning\Models\PrunableTestModelWithPrunableRecords::class, + ]); + } + + public function testPrunableModelWithPrunableRecords() + { + $output = $this->artisan(['--model' => Pruning\Models\PrunableTestModelWithPrunableRecords::class]); + + $output = $output->fetch(); + + $this->assertStringContainsString( + 'Illuminate\Tests\Database\Pruning\Models\PrunableTestModelWithPrunableRecords', + $output, + ); + + $this->assertStringContainsString( + '10 records', + $output, + ); + + $this->assertStringContainsString( + 'Illuminate\Tests\Database\Pruning\Models\PrunableTestModelWithPrunableRecords', + $output, + ); + + $this->assertStringContainsString( + '20 records', + $output, + ); + } + + public function testPrunableTestModelWithoutPrunableRecords() + { + $output = $this->artisan(['--model' => Pruning\Models\PrunableTestModelWithoutPrunableRecords::class]); + + $this->assertStringContainsString( + 'No prunable [Illuminate\Tests\Database\Pruning\Models\PrunableTestModelWithoutPrunableRecords] records found.', + $output->fetch() + ); + } + + public function testPrunableSoftDeletedModelWithPrunableRecords() + { + $db = new DB; + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + DB::connection('default')->getSchemaBuilder()->create('prunables', function ($table) { + $table->string('value')->nullable(); + $table->datetime('deleted_at')->nullable(); + }); + DB::connection('default')->table('prunables')->insert([ + ['value' => 1, 'deleted_at' => null], + ['value' => 2, 'deleted_at' => '2021-12-01 00:00:00'], + ['value' => 3, 'deleted_at' => null], + ['value' => 4, 'deleted_at' => '2021-12-02 00:00:00'], + ]); + + $output = $this->artisan(['--model' => Pruning\Models\PrunableTestSoftDeletedModelWithPrunableRecords::class]); + + $output = $output->fetch(); + + $this->assertStringContainsString( + 'Illuminate\Tests\Database\Pruning\Models\PrunableTestSoftDeletedModelWithPrunableRecords', + $output, + ); + + $this->assertStringContainsString( + '2 records', + $output, + ); + + $this->assertEquals(2, Pruning\Models\PrunableTestSoftDeletedModelWithPrunableRecords::withTrashed()->count()); + } + + public function testNonPrunableTest() + { + $output = $this->artisan(['--model' => Pruning\Models\NonPrunableTestModel::class]); + + $this->assertStringContainsString( + 'No prunable [Illuminate\Tests\Database\Pruning\Models\NonPrunableTestModel] records found.', + $output->fetch(), + ); + } + + public function testNonPrunableTestWithATrait() + { + $output = $this->artisan(['--model' => Pruning\Models\NonPrunableTrait::class]); + + $this->assertStringContainsString( + 'No prunable models found.', + $output->fetch(), + ); + } + + public function testNonModelFilesAreIgnoredTest() + { + $output = $this->artisan(['--path' => 'Models']); + + $output = $output->fetch(); + + $this->assertStringNotContainsString( + 'No prunable [Illuminate\Tests\Database\Pruning\Models\AbstractPrunableModel] records found.', + $output, + ); + + $this->assertStringNotContainsString( + 'No prunable [Illuminate\Tests\Database\Pruning\Models\SomeClass] records found.', + $output, + ); + + $this->assertStringNotContainsString( + 'No prunable [Illuminate\Tests\Database\Pruning\Models\SomeEnum] records found.', + $output, + ); + } + + public function testTheCommandMayBePretended() + { + $db = new DB; + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + DB::connection('default')->getSchemaBuilder()->create('prunables', function ($table) { + $table->string('name')->nullable(); + $table->string('value')->nullable(); + }); + DB::connection('default')->table('prunables')->insert([ + ['name' => 'zain', 'value' => 1], + ['name' => 'patrice', 'value' => 2], + ['name' => 'amelia', 'value' => 3], + ['name' => 'stuart', 'value' => 4], + ['name' => 'bello', 'value' => 5], + ]); + + $output = $this->artisan([ + '--model' => Pruning\Models\PrunableTestModelWithPrunableRecords::class, + '--pretend' => true, + ]); + + $this->assertStringContainsString( + '3 [Illuminate\Tests\Database\Pruning\Models\PrunableTestModelWithPrunableRecords] records will be pruned.', + $output->fetch(), + ); + + $this->assertEquals(5, Pruning\Models\PrunableTestModelWithPrunableRecords::count()); + } + + public function testTheCommandMayBePretendedOnSoftDeletedModel() + { + $db = new DB; + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + DB::connection('default')->getSchemaBuilder()->create('prunables', function ($table) { + $table->string('value')->nullable(); + $table->datetime('deleted_at')->nullable(); + }); + DB::connection('default')->table('prunables')->insert([ + ['value' => 1, 'deleted_at' => null], + ['value' => 2, 'deleted_at' => '2021-12-01 00:00:00'], + ['value' => 3, 'deleted_at' => null], + ['value' => 4, 'deleted_at' => '2021-12-02 00:00:00'], + ]); + + $output = $this->artisan([ + '--model' => Pruning\Models\PrunableTestSoftDeletedModelWithPrunableRecords::class, + '--pretend' => true, + ]); + + $this->assertStringContainsString( + '2 [Illuminate\Tests\Database\Pruning\Models\PrunableTestSoftDeletedModelWithPrunableRecords] records will be pruned.', + $output->fetch(), + ); + + $this->assertEquals(4, Pruning\Models\PrunableTestSoftDeletedModelWithPrunableRecords::withTrashed()->count()); + } + + public function testTheCommandDispatchesEvents() + { + $dispatcher = m::mock(DispatcherContract::class); + + $dispatcher->shouldReceive('dispatch')->once()->withArgs(function ($event) { + return get_class($event) === ModelPruningStarting::class && + $event->models === [Pruning\Models\PrunableTestModelWithPrunableRecords::class]; + }); + $dispatcher->shouldReceive('listen')->once()->with(ModelsPruned::class, m::type(Closure::class)); + $dispatcher->shouldReceive('dispatch')->twice()->with(m::type(ModelsPruned::class)); + $dispatcher->shouldReceive('dispatch')->once()->withArgs(function ($event) { + return get_class($event) === ModelPruningFinished::class && + $event->models === [Pruning\Models\PrunableTestModelWithPrunableRecords::class]; + }); + $dispatcher->shouldReceive('forget')->once()->with(ModelsPruned::class); + + Application::getInstance()->instance(DispatcherContract::class, $dispatcher); + + $this->artisan(['--model' => Pruning\Models\PrunableTestModelWithPrunableRecords::class]); + } + + protected function artisan($arguments) + { + $input = new ArrayInput($arguments); + $output = new BufferedOutput; + + tap(new PruneCommand()) + ->setLaravel(Application::getInstance()) + ->run($input, $output); + + return $output; + } + + protected function tearDown(): void + { + Application::setInstance(null); + + parent::tearDown(); + } +} diff --git a/tests/Database/Laravel/Pruning/Models/AbstractPrunableModel.php b/tests/Database/Laravel/Pruning/Models/AbstractPrunableModel.php new file mode 100644 index 000000000..63f4792ff --- /dev/null +++ b/tests/Database/Laravel/Pruning/Models/AbstractPrunableModel.php @@ -0,0 +1,13 @@ +=', 3); + } +} diff --git a/tests/Database/Laravel/Pruning/Models/PrunableTestModelWithoutPrunableRecords.php b/tests/Database/Laravel/Pruning/Models/PrunableTestModelWithoutPrunableRecords.php new file mode 100644 index 000000000..c9667cd2e --- /dev/null +++ b/tests/Database/Laravel/Pruning/Models/PrunableTestModelWithoutPrunableRecords.php @@ -0,0 +1,18 @@ +=', 3); + } +} diff --git a/tests/Database/Laravel/Pruning/Models/SomeClass.php b/tests/Database/Laravel/Pruning/Models/SomeClass.php new file mode 100644 index 000000000..bee7410b3 --- /dev/null +++ b/tests/Database/Laravel/Pruning/Models/SomeClass.php @@ -0,0 +1,9 @@ +setEventDispatcher(new Dispatcher()); + $called = 0; + $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1.1), function () use (&$called) { + $called++; + }); + + $connection->logQuery('xxxx', [], 1.0); + $connection->logQuery('xxxx', [], 0.1); + $this->assertSame(0, $called); + + $connection->logQuery('xxxx', [], 0.1); + $this->assertSame(1, $called); + } + + public function testItIsOnlyCalledOnce() + { + $connection = new Connection(new PDO('sqlite::memory:')); + $connection->setEventDispatcher(new Dispatcher()); + $called = 0; + $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function () use (&$called) { + $called++; + }); + + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + + $this->assertSame(1, $called); + } + + public function testItIsOnlyCalledOnceWhenGivenDateTime() + { + Carbon::setTestNow($this->now = Carbon::create(2017, 6, 27, 13, 14, 15, 'UTC')); + + $connection = new Connection(new PDO('sqlite::memory:')); + $connection->setEventDispatcher(new Dispatcher()); + $called = 0; + $connection->whenQueryingForLongerThan($this->now->addMilliseconds(1), function () use (&$called) { + $called++; + }); + + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + + $this->assertSame(1, $called); + } + + public function testItCanSpecifyMultipleHandlersWithTheSameIntervals() + { + $connection = new Connection(new PDO('sqlite::memory:')); + $connection->setEventDispatcher(new Dispatcher()); + $called = []; + $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function () use (&$called) { + $called['a'] = true; + }); + $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function () use (&$called) { + $called['b'] = true; + }); + + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + + $this->assertSame([ + 'a' => true, + 'b' => true, + ], $called); + } + + public function testItCanSpecifyMultipleHandlersWithDifferentIntervals() + { + $connection = new Connection(new PDO('sqlite::memory:')); + $connection->setEventDispatcher(new Dispatcher()); + $called = []; + $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function () use (&$called) { + $called['a'] = true; + }); + $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(2), function () use (&$called) { + $called['b'] = true; + }); + + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + $this->assertSame([ + 'a' => true, + ], $called); + + $connection->logQuery('xxxx', [], 1); + $this->assertSame([ + 'a' => true, + 'b' => true, + ], $called); + } + + public function testItHasAccessToConnectionInHandler() + { + $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'expected-name']); + $connection->setEventDispatcher(new Dispatcher()); + $name = null; + $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function ($connection) use (&$name) { + $name = $connection->getName(); + }); + + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + + $this->assertSame('expected-name', $name); + } + + public function testItHasSpecifyThresholdWithFloat() + { + $connection = new Connection(new PDO('sqlite::memory:')); + $connection->setEventDispatcher(new Dispatcher()); + $called = false; + $connection->whenQueryingForLongerThan(1.1, function () use (&$called) { + $called = true; + }); + + $connection->logQuery('xxxx', [], 1.1); + $this->assertFalse($called); + + $connection->logQuery('xxxx', [], 0.1); + $this->assertTrue($called); + } + + public function testItHasSpecifyThresholdWithInt() + { + $connection = new Connection(new PDO('sqlite::memory:')); + $connection->setEventDispatcher(new Dispatcher()); + $called = false; + $connection->whenQueryingForLongerThan(2, function () use (&$called) { + $called = true; + }); + + $connection->logQuery('xxxx', [], 1.1); + $this->assertFalse($called); + + $connection->logQuery('xxxx', [], 1.0); + $this->assertTrue($called); + } + + public function testItCanResetTotalQueryDuration() + { + $connection = new Connection(new PDO('sqlite::memory:')); + $connection->setEventDispatcher(new Dispatcher()); + + $connection->logQuery('xxxx', [], 1.1); + $this->assertSame(1.1, $connection->totalQueryDuration()); + $connection->logQuery('xxxx', [], 1.1); + $this->assertSame(2.2, $connection->totalQueryDuration()); + + $connection->resetTotalQueryDuration(); + $this->assertSame(0.0, $connection->totalQueryDuration()); + } + + public function testItCanRestoreAlreadyRunHandlers() + { + $connection = new Connection(new PDO('sqlite::memory:')); + $connection->setEventDispatcher(new Dispatcher()); + $called = 0; + $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function () use (&$called) { + $called++; + }); + + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + $this->assertSame(1, $called); + + $connection->allowQueryDurationHandlersToRunAgain(); + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + $this->assertSame(2, $called); + + $connection->allowQueryDurationHandlersToRunAgain(); + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + $connection->logQuery('xxxx', [], 1); + $this->assertSame(3, $called); + } + + public function testItCanAccessAllQueriesWhenQueryLoggingIsActive() + { + $connection = new Connection(new PDO('sqlite::memory:')); + $connection->setEventDispatcher(new Dispatcher()); + $connection->enableQueryLog(); + $queries = []; + $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(2), function ($connection, $event) use (&$queries) { + $queries = Arr::pluck($connection->getQueryLog(), 'query'); + $queries[] = $event->sql; + }); + + $connection->logQuery('foo', [], 1); + $connection->logQuery('bar', [], 1); + $connection->logQuery('baz', [], 1); + + $this->assertSame([ + 'foo', + 'bar', + 'baz', + ], $queries); + } +} diff --git a/tests/Database/Laravel/SeedCommandTest.php b/tests/Database/Laravel/SeedCommandTest.php new file mode 100644 index 000000000..2db193491 --- /dev/null +++ b/tests/Database/Laravel/SeedCommandTest.php @@ -0,0 +1,154 @@ + true, '--database' => 'sqlite']); + $output = new NullOutput; + $outputStyle = new OutputStyle($input, $output); + + $seeder = m::mock(Seeder::class); + $seeder->shouldReceive('setContainer')->once()->andReturnSelf(); + $seeder->shouldReceive('setCommand')->once()->andReturnSelf(); + $seeder->shouldReceive('__invoke')->once(); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('getDefaultConnection')->once(); + $resolver->shouldReceive('setDefaultConnection')->once()->with('sqlite'); + + $container = m::mock(Container::class); + $container->shouldReceive('call'); + $container->shouldReceive('environment')->once()->andReturn('testing'); + $container->shouldReceive('runningUnitTests')->andReturn('true'); + $container->shouldReceive('make')->with('DatabaseSeeder')->andReturn($seeder); + $container->shouldReceive('make')->with(OutputStyle::class, m::any())->andReturn( + $outputStyle + ); + $container->shouldReceive('make')->with(Factory::class, m::any())->andReturn( + new Factory($outputStyle) + ); + + $command = new SeedCommand($resolver); + $command->setLaravel($container); + + // call run to set up IO, then fire manually. + $command->run($input, $output); + $command->handle(); + + $container->shouldHaveReceived('call')->with([$command, 'handle']); + } + + public function testWithoutModelEvents() + { + $input = new ArrayInput([ + '--force' => true, + '--database' => 'sqlite', + '--class' => UserWithoutModelEventsSeeder::class, + ]); + $output = new NullOutput; + $outputStyle = new OutputStyle($input, $output); + + $instance = new UserWithoutModelEventsSeeder(); + + $seeder = m::mock($instance); + $seeder->shouldReceive('setContainer')->once()->andReturnSelf(); + $seeder->shouldReceive('setCommand')->once()->andReturnSelf(); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('getDefaultConnection')->once(); + $resolver->shouldReceive('setDefaultConnection')->once()->with('sqlite'); + + $container = m::mock(Container::class); + $container->shouldReceive('call'); + $container->shouldReceive('environment')->once()->andReturn('testing'); + $container->shouldReceive('runningUnitTests')->andReturn('true'); + $container->shouldReceive('make')->with(UserWithoutModelEventsSeeder::class)->andReturn($seeder); + $container->shouldReceive('make')->with(OutputStyle::class, m::any())->andReturn( + $outputStyle + ); + $container->shouldReceive('make')->with(Factory::class, m::any())->andReturn( + new Factory($outputStyle) + ); + + $command = new SeedCommand($resolver); + $command->setLaravel($container); + + Model::setEventDispatcher($dispatcher = m::mock(Dispatcher::class)); + + // call run to set up IO, then fire manually. + $command->run($input, $output); + $command->handle(); + + Assert::assertSame($dispatcher, Model::getEventDispatcher()); + + $container->shouldHaveReceived('call')->with([$command, 'handle']); + } + + public function testProhibitable() + { + $input = new ArrayInput([]); + $output = new NullOutput; + $outputStyle = new OutputStyle($input, $output); + + $resolver = m::mock(ConnectionResolverInterface::class); + + $container = m::mock(Container::class); + $container->shouldReceive('call'); + $container->shouldReceive('runningUnitTests')->andReturn('true'); + $container->shouldReceive('make')->with(OutputStyle::class, m::any())->andReturn( + $outputStyle + ); + $container->shouldReceive('make')->with(Factory::class, m::any())->andReturn( + new Factory($outputStyle) + ); + + $command = new SeedCommand($resolver); + $command->setLaravel($container); + + // call run to set up IO, then fire manually. + $command->run($input, $output); + + SeedCommand::prohibit(); + + Assert::assertSame(Command::FAILURE, $command->handle()); + } + + protected function tearDown(): void + { + SeedCommand::prohibit(false); + + Model::unsetEventDispatcher(); + + parent::tearDown(); + } +} + +class UserWithoutModelEventsSeeder extends Seeder +{ + use WithoutModelEvents; + + public function run() + { + Assert::assertInstanceOf(NullDispatcher::class, Model::getEventDispatcher()); + } +} diff --git a/tests/Database/Laravel/TableGuesserTest.php b/tests/Database/Laravel/TableGuesserTest.php new file mode 100644 index 000000000..43d880472 --- /dev/null +++ b/tests/Database/Laravel/TableGuesserTest.php @@ -0,0 +1,55 @@ +assertSame('users', $table); + $this->assertTrue($create); + + [$table, $create] = TableGuesser::guess('add_status_column_to_users_table'); + $this->assertSame('users', $table); + $this->assertFalse($create); + + [$table, $create] = TableGuesser::guess('add_is_sent_to_crm_column_to_users_table'); + $this->assertSame('users', $table); + $this->assertFalse($create); + + [$table, $create] = TableGuesser::guess('change_status_column_in_users_table'); + $this->assertSame('users', $table); + $this->assertFalse($create); + + [$table, $create] = TableGuesser::guess('drop_status_column_from_users_table'); + $this->assertSame('users', $table); + $this->assertFalse($create); + } + + public function testMigrationIsProperlyParsedWithoutTableSuffix() + { + [$table, $create] = TableGuesser::guess('create_users'); + $this->assertSame('users', $table); + $this->assertTrue($create); + + [$table, $create] = TableGuesser::guess('add_status_column_to_users'); + $this->assertSame('users', $table); + $this->assertFalse($create); + + [$table, $create] = TableGuesser::guess('add_is_sent_to_crm_column_column_to_users'); + $this->assertSame('users', $table); + $this->assertFalse($create); + + [$table, $create] = TableGuesser::guess('change_status_column_in_users'); + $this->assertSame('users', $table); + $this->assertFalse($create); + + [$table, $create] = TableGuesser::guess('drop_status_column_from_users'); + $this->assertSame('users', $table); + $this->assertFalse($create); + } +} diff --git a/tests/Database/Laravel/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php b/tests/Database/Laravel/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php new file mode 100644 index 000000000..44fb82778 --- /dev/null +++ b/tests/Database/Laravel/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php @@ -0,0 +1,44 @@ +id(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/tests/Database/Laravel/migrations/connection_configured/2022_02_21_120000_create_jobs_table.php b/tests/Database/Laravel/migrations/connection_configured/2022_02_21_120000_create_jobs_table.php new file mode 100644 index 000000000..605a880ee --- /dev/null +++ b/tests/Database/Laravel/migrations/connection_configured/2022_02_21_120000_create_jobs_table.php @@ -0,0 +1,38 @@ +create('jobs', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::connection('sqlite3')->dropIfExists('jobs'); + } +}; diff --git a/tests/Database/Laravel/migrations/multi_path/app/2016_01_01_000000_create_users_table.php b/tests/Database/Laravel/migrations/multi_path/app/2016_01_01_000000_create_users_table.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000001_rename_table_one.php b/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000001_rename_table_one.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000002_rename_table_two.php b/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000002_rename_table_two.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000003_rename_table_three.php b/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000003_rename_table_three.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000004_rename_table_four.php b/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000004_rename_table_four.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000005_create_table_one.php b/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000005_create_table_one.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000006_create_table_two.php b/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000006_create_table_two.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000008_create_table_four.php b/tests/Database/Laravel/migrations/multi_path/app/2019_08_08_000008_create_table_four.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/vendor/2016_01_01_200000_create_flights_table.php b/tests/Database/Laravel/migrations/multi_path/vendor/2016_01_01_200000_create_flights_table.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000001_rename_table_one.php b/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000001_rename_table_one.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000002_rename_table_two.php b/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000002_rename_table_two.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000003_rename_table_three.php b/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000003_rename_table_three.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000004_rename_table_four.php b/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000004_rename_table_four.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000005_create_table_one.php b/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000005_create_table_one.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000006_create_table_two.php b/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000006_create_table_two.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000007_create_table_three.php b/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000007_create_table_three.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000008_create_table_four.php b/tests/Database/Laravel/migrations/multi_path/vendor/2019_08_08_000008_create_table_four.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Laravel/migrations/one/2016_01_01_000000_create_users_table.php b/tests/Database/Laravel/migrations/one/2016_01_01_000000_create_users_table.php new file mode 100644 index 000000000..36b7f6595 --- /dev/null +++ b/tests/Database/Laravel/migrations/one/2016_01_01_000000_create_users_table.php @@ -0,0 +1,37 @@ +increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('users'); + } +} diff --git a/tests/Database/Laravel/migrations/one/2016_01_01_100000_create_password_resets_table.php b/tests/Database/Laravel/migrations/one/2016_01_01_100000_create_password_resets_table.php new file mode 100644 index 000000000..850ea9782 --- /dev/null +++ b/tests/Database/Laravel/migrations/one/2016_01_01_100000_create_password_resets_table.php @@ -0,0 +1,34 @@ +string('email')->index(); + $table->string('token')->index(); + $table->timestamp('created_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('password_resets'); + } +} diff --git a/tests/Database/Laravel/migrations/should_run/2016_01_01_200000_create_flights_table.php b/tests/Database/Laravel/migrations/should_run/2016_01_01_200000_create_flights_table.php new file mode 100644 index 000000000..b59525119 --- /dev/null +++ b/tests/Database/Laravel/migrations/should_run/2016_01_01_200000_create_flights_table.php @@ -0,0 +1,38 @@ +increments('id'); + $table->string('name'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('flights'); + } +} diff --git a/tests/Database/Laravel/migrations/two/2016_01_01_200000_create_flights_table.php b/tests/Database/Laravel/migrations/two/2016_01_01_200000_create_flights_table.php new file mode 100644 index 000000000..977195da3 --- /dev/null +++ b/tests/Database/Laravel/migrations/two/2016_01_01_200000_create_flights_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->string('name'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('flights'); + } +} diff --git a/tests/Database/Laravel/stubs/EloquentModelNamespacedStub.php b/tests/Database/Laravel/stubs/EloquentModelNamespacedStub.php new file mode 100755 index 000000000..1efa070b3 --- /dev/null +++ b/tests/Database/Laravel/stubs/EloquentModelNamespacedStub.php @@ -0,0 +1,12 @@ + null, + ]; + } + + return [ + $key => json_encode($value->toArray()), + ]; + } +} diff --git a/tests/Database/Laravel/stubs/TestEnum.php b/tests/Database/Laravel/stubs/TestEnum.php new file mode 100644 index 000000000..5be34b63e --- /dev/null +++ b/tests/Database/Laravel/stubs/TestEnum.php @@ -0,0 +1,10 @@ +myPropertyA = $test['myPropertyA']; + } + if (! empty($test['myPropertyB'])) { + $self->myPropertyB = $test['myPropertyB']; + } + + return $self; + } + + public function toArray(): array + { + if (isset($this->myPropertyA)) { + $result['myPropertyA'] = $this->myPropertyA; + } + if (isset($this->myPropertyB)) { + $result['myPropertyB'] = $this->myPropertyB; + } + + return $result ?? []; + } +} diff --git a/tests/Database/Laravel/stubs/schema.sql b/tests/Database/Laravel/stubs/schema.sql new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Database/Listeners/RegisterSQLiteConnectionListenerTest.php b/tests/Database/Listeners/RegisterSQLiteConnectionListenerTest.php new file mode 100644 index 000000000..375bb3622 --- /dev/null +++ b/tests/Database/Listeners/RegisterSQLiteConnectionListenerTest.php @@ -0,0 +1,167 @@ +clearSQLiteResolver(); + + parent::tearDown(); + } + + public function testListensToBootApplicationEvent(): void + { + $listener = new RegisterSQLiteConnectionListener($this->app); + + $this->assertSame([BootApplication::class], $listener->listen()); + } + + public function testRegistersResolverForSQLiteDriver(): void + { + // Clear any existing resolver + $this->clearSQLiteResolver(); + $this->assertNull(Connection::getResolver('sqlite')); + + $listener = new RegisterSQLiteConnectionListener($this->app); + $listener->process(new BootApplication()); + + $this->assertNotNull(Connection::getResolver('sqlite')); + } + + public function testResolverReturnsSQLiteConnection(): void + { + $listener = new RegisterSQLiteConnectionListener($this->app); + $listener->process(new BootApplication()); + + $resolver = Connection::getResolver('sqlite'); + $pdo = new PDO('sqlite::memory:'); + $connection = $resolver($pdo, ':memory:', '', ['database' => '/tmp/test.db', 'name' => 'test']); + + $this->assertInstanceOf(SQLiteConnection::class, $connection); + } + + /** + * @dataProvider inMemoryDatabaseProvider + */ + public function testIsInMemoryDatabaseDetection(string $database, bool $expected): void + { + $listener = new RegisterSQLiteConnectionListener($this->app); + + $method = new ReflectionMethod($listener, 'isInMemoryDatabase'); + + $this->assertSame($expected, $method->invoke($listener, $database)); + } + + public static function inMemoryDatabaseProvider(): array + { + return [ + 'standard memory' => [':memory:', true], + 'query string mode=memory' => ['file:test?mode=memory', true], + 'ampersand mode=memory' => ['file:test?cache=shared&mode=memory', true], + 'mode=memory at end' => ['file:test?other=value&mode=memory', true], + 'regular file path' => ['/tmp/database.sqlite', false], + 'relative path' => ['database.sqlite', false], + 'empty string' => ['', false], + 'memory in path name' => ['/tmp/memory.sqlite', false], + 'mode_memory without equals' => ['file:test?mode_memory', false], + ]; + } + + public function testPersistentPdoIsSharedForInMemoryDatabase(): void + { + $listener = new RegisterSQLiteConnectionListener($this->app); + $listener->process(new BootApplication()); + + $resolver = Connection::getResolver('sqlite'); + + $config = ['database' => ':memory:', 'name' => 'test_memory']; + + // Create a PDO closure that creates a new PDO each time + $pdoFactory = fn () => new PDO('sqlite::memory:'); + + // First call should create and store the PDO + $connection1 = $resolver($pdoFactory, ':memory:', '', $config); + $pdo1 = $connection1->getPdo(); + + // Second call should return the same PDO + $connection2 = $resolver($pdoFactory, ':memory:', '', $config); + $pdo2 = $connection2->getPdo(); + + $this->assertSame($pdo1, $pdo2, 'In-memory database should share the same PDO instance'); + } + + public function testFileDatabaseDoesNotSharePdo(): void + { + $listener = new RegisterSQLiteConnectionListener($this->app); + $listener->process(new BootApplication()); + + $resolver = Connection::getResolver('sqlite'); + + // Create a temp file for testing + $tempFile = sys_get_temp_dir() . '/test_sqlite_' . uniqid() . '.db'; + touch($tempFile); + + try { + $config = ['database' => $tempFile, 'name' => 'test_file']; + + // Create a PDO closure that creates a new PDO each time + $pdoFactory = fn () => new PDO("sqlite:{$tempFile}"); + + // Each call should create a new PDO + $connection1 = $resolver($pdoFactory, $tempFile, '', $config); + $pdo1 = $connection1->getPdo(); + + $connection2 = $resolver($pdoFactory, $tempFile, '', $config); + $pdo2 = $connection2->getPdo(); + + $this->assertNotSame($pdo1, $pdo2, 'File-based database should NOT share PDO instances'); + } finally { + @unlink($tempFile); + } + } + + public function testDifferentNamedInMemoryConnectionsGetDifferentPdos(): void + { + $listener = new RegisterSQLiteConnectionListener($this->app); + $listener->process(new BootApplication()); + + $resolver = Connection::getResolver('sqlite'); + + $pdoFactory = fn () => new PDO('sqlite::memory:'); + + $connection1 = $resolver($pdoFactory, ':memory:', '', ['database' => ':memory:', 'name' => 'memory_one']); + $pdo1 = $connection1->getPdo(); + + $connection2 = $resolver($pdoFactory, ':memory:', '', ['database' => ':memory:', 'name' => 'memory_two']); + $pdo2 = $connection2->getPdo(); + + $this->assertNotSame($pdo1, $pdo2, 'Different named connections should have different PDO instances'); + } + + protected function clearSQLiteResolver(): void + { + // Use reflection to clear the resolver + $property = new ReflectionProperty(Connection::class, 'resolvers'); + $resolvers = $property->getValue(); + unset($resolvers['sqlite']); + $property->setValue(null, $resolvers); + } +} diff --git a/tests/Database/Query/QueryTestCase.php b/tests/Database/Query/QueryTestCase.php new file mode 100644 index 000000000..6ab51534f --- /dev/null +++ b/tests/Database/Query/QueryTestCase.php @@ -0,0 +1,74 @@ +getMySqlBuilder(); + } + + protected function getMySqlBuilder(): Builder + { + return new Builder( + $this->getMockConnection(), + new MySqlGrammar(), + m::mock(Processor::class) + ); + } + + protected function getPostgresBuilder(): Builder + { + return new Builder( + $this->getMockConnection(), + new PostgresGrammar(), + m::mock(Processor::class) + ); + } + + protected function getSQLiteBuilder(): Builder + { + return new Builder( + $this->getMockConnection(), + new SQLiteGrammar(), + m::mock(Processor::class) + ); + } + + protected function getMockConnection(): ConnectionInterface + { + $connection = m::mock(ConnectionInterface::class); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('raw')->andReturnUsing( + fn ($value) => new Expression($value) + ); + + return $connection; + } +} diff --git a/tests/Encryption/EncrypterTest.php b/tests/Encryption/EncrypterTest.php index 86efe364c..96d100c92 100644 --- a/tests/Encryption/EncrypterTest.php +++ b/tests/Encryption/EncrypterTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Encryption; +use Hypervel\Contracts\Encryption\DecryptException; use Hypervel\Encryption\Encrypter; -use Hypervel\Encryption\Exceptions\DecryptException; use Hypervel\Tests\TestCase; use RuntimeException; diff --git a/tests/Event/BroadcastedEventsTest.php b/tests/Event/BroadcastedEventsTest.php index 9cee514c5..1fc4628b6 100644 --- a/tests/Event/BroadcastedEventsTest.php +++ b/tests/Event/BroadcastedEventsTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Event; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastFactory; -use Hypervel\Broadcasting\Contracts\ShouldBroadcast; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastFactory; +use Hypervel\Contracts\Broadcasting\ShouldBroadcast; use Hypervel\Event\EventDispatcher; use Hypervel\Event\ListenerProvider; use Mockery as m; @@ -18,11 +18,6 @@ */ class BroadcastedEventsTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testShouldBroadcastSuccess() { $d = m::mock(EventDispatcher::class); diff --git a/tests/Event/EventsDispatcherTest.php b/tests/Event/EventsDispatcherTest.php index 627afd092..66468a6c1 100644 --- a/tests/Event/EventsDispatcherTest.php +++ b/tests/Event/EventsDispatcherTest.php @@ -6,8 +6,8 @@ use Error; use Exception; -use Hypervel\Database\TransactionManager; -use Hypervel\Event\Contracts\ShouldDispatchAfterCommit; +use Hypervel\Contracts\Event\ShouldDispatchAfterCommit; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Event\EventDispatcher; use Hypervel\Event\ListenerProvider; use Hypervel\Tests\TestCase; @@ -198,16 +198,19 @@ public function testHaltingEventExecution() $d = $this->getEventDispatcher(); $d->listen('foo', function () { $this->assertTrue(true); + + return 'halted'; }); $d->listen('foo', function () { throw new Exception('should not be called'); }); + // With halt=true, returns first non-null response $response = $d->dispatch('foo', ['bar'], true); - $this->assertEquals('foo', $response); + $this->assertEquals('halted', $response); $response = $d->until('foo', ['bar']); - $this->assertEquals('foo', $response); + $this->assertEquals('halted', $response); } public function testResponseWhenNoListenersAreSet() @@ -817,7 +820,7 @@ public function testGetRawListeners() public function testDispatchWithAfterCommit() { - $transactionResolver = Mockery::mock(TransactionManager::class); + $transactionResolver = Mockery::mock(DatabaseTransactionsManager::class); $transactionResolver ->shouldReceive('addCallback') ->once(); diff --git a/tests/Event/QueuedEventsTest.php b/tests/Event/QueuedEventsTest.php index 24d1bdd5f..7bd26c9a4 100644 --- a/tests/Event/QueuedEventsTest.php +++ b/tests/Event/QueuedEventsTest.php @@ -9,13 +9,13 @@ use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\StdoutLoggerInterface; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Bus\Contracts\Dispatcher; use Hypervel\Container\Container; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Queue\Factory as QueueFactoryContract; +use Hypervel\Contracts\Queue\Queue as QueueContract; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Event\EventDispatcher; use Hypervel\Event\ListenerProvider; -use Hypervel\Queue\Contracts\Factory as QueueFactoryContract; -use Hypervel\Queue\Contracts\Queue as QueueContract; -use Hypervel\Queue\Contracts\ShouldQueue; use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Tests\TestCase; use Illuminate\Events\CallQueuedListener; diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index e75c2afc0..f7fbf8177 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -10,10 +10,10 @@ use Hyperf\Context\Context; use Hyperf\Coroutine\Coroutine; use Hyperf\HttpMessage\Upload\UploadedFile; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Hypervel\Filesystem\FilesystemAdapter; use Hypervel\Filesystem\FilesystemManager; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; use Hypervel\Http\Response; use InvalidArgumentException; use League\Flysystem\Filesystem; @@ -59,7 +59,6 @@ protected function tearDown(): void $this->adapter = new LocalFilesystemAdapter(dirname($this->tempDir)) ); $filesystem->deleteDirectory(basename($this->tempDir)); - m::close(); unset($this->tempDir, $this->filesystem, $this->adapter); } diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 5402f47f4..928d99dd9 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -10,7 +10,7 @@ use Hyperf\Contract\ContainerInterface; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Filesystem\Contracts\Filesystem; +use Hypervel\Contracts\Filesystem\Filesystem; use Hypervel\Filesystem\FilesystemManager; use Hypervel\Filesystem\FilesystemPoolProxy; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index b0d74a77b..87850e740 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Foundation; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Event\EventDispatcher; use Hypervel\Event\ListenerProvider; use Hypervel\Foundation\Bootstrap\RegisterFacades; @@ -14,7 +15,6 @@ use Hypervel\Support\ServiceProvider; use Hypervel\Tests\Foundation\Concerns\HasMockedApplication; use Hypervel\Tests\TestCase; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Mockery as m; use Psr\EventDispatcher\EventDispatcherInterface; use stdClass; diff --git a/tests/Foundation/FoundationExceptionHandlerTest.php b/tests/Foundation/FoundationExceptionHandlerTest.php index 60f8a3203..7144e4536 100644 --- a/tests/Foundation/FoundationExceptionHandlerTest.php +++ b/tests/Foundation/FoundationExceptionHandlerTest.php @@ -10,7 +10,6 @@ use Hyperf\Context\ResponseContext; use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\SessionInterface; -use Hyperf\Database\Model\ModelNotFoundException; use Hyperf\Di\MethodDefinitionCollector; use Hyperf\Di\MethodDefinitionCollectorInterface; use Hyperf\HttpMessage\Exception\HttpException; @@ -21,14 +20,15 @@ use Hyperf\ViewEngine\ViewErrorBag; use Hypervel\Config\Repository; use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Http\Response as ResponseContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Session\Session as SessionContract; +use Hypervel\Contracts\Support\Responsable; +use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Foundation\Exceptions\Handler; -use Hypervel\Http\Contracts\ResponseContract; use Hypervel\Http\Request; use Hypervel\Http\Response; use Hypervel\HttpMessage\Exceptions\AccessDeniedHttpException; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; -use Hypervel\Session\Contracts\Session as SessionContract; -use Hypervel\Support\Contracts\Responsable; use Hypervel\Support\Facades\View; use Hypervel\Support\MessageBag; use Hypervel\Tests\Foundation\Concerns\HasMockedApplication; diff --git a/tests/Foundation/Http/CustomCastingTest.php b/tests/Foundation/Http/CustomCastingTest.php index eff716c13..37d857d1d 100644 --- a/tests/Foundation/Http/CustomCastingTest.php +++ b/tests/Foundation/Http/CustomCastingTest.php @@ -7,7 +7,6 @@ use ArrayObject; use Carbon\Carbon; use Carbon\CarbonInterface; -use Hyperf\Collection\Collection; use Hyperf\Context\Context; use Hypervel\Foundation\Http\Casts\AsDataObjectArray; use Hypervel\Foundation\Http\Casts\AsDataObjectCollection; @@ -16,6 +15,7 @@ use Hypervel\Foundation\Http\Contracts\CastInputs; use Hypervel\Foundation\Http\FormRequest; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; +use Hypervel\Support\Collection; use Hypervel\Support\DataObject; use Hypervel\Testbench\TestCase; use Hypervel\Validation\Rule; diff --git a/tests/Foundation/Stubs/User.php b/tests/Foundation/Stubs/User.php new file mode 100644 index 000000000..265135ce5 --- /dev/null +++ b/tests/Foundation/Stubs/User.php @@ -0,0 +1,36 @@ + $this->faker->name(), + 'email' => $this->faker->unique()->safeEmail(), + ]; + } +} diff --git a/tests/Foundation/Testing/Attributes/AttributesTest.php b/tests/Foundation/Testing/Attributes/AttributesTest.php new file mode 100644 index 000000000..5b1d9369e --- /dev/null +++ b/tests/Foundation/Testing/Attributes/AttributesTest.php @@ -0,0 +1,257 @@ +assertInstanceOf(Actionable::class, $attribute); + $this->assertSame('someMethod', $attribute->method); + } + + public function testDefineEnvironmentCallsMethod(): void + { + $attribute = new DefineEnvironment('testMethod'); + $called = false; + $receivedArgs = null; + + $action = function (string $method, array $params) use (&$called, &$receivedArgs) { + $called = true; + $receivedArgs = [$method, $params]; + }; + + $attribute->handle($this->app, $action); + + $this->assertTrue($called); + $this->assertSame('testMethod', $receivedArgs[0]); + $this->assertSame([$this->app], $receivedArgs[1]); + } + + public function testWithConfigImplementsInvokable(): void + { + $attribute = new WithConfig('app.name', 'TestApp'); + + $this->assertInstanceOf(Invokable::class, $attribute); + $this->assertSame('app.name', $attribute->key); + $this->assertSame('TestApp', $attribute->value); + } + + public function testWithConfigSetsConfigValue(): void + { + $attribute = new WithConfig('testing.attributes.key', 'test_value'); + + $attribute($this->app); + + $this->assertSame('test_value', $this->app->get('config')->get('testing.attributes.key')); + } + + public function testDefineRouteImplementsActionable(): void + { + $attribute = new DefineRoute('defineTestRoutes'); + + $this->assertInstanceOf(Actionable::class, $attribute); + $this->assertSame('defineTestRoutes', $attribute->method); + } + + public function testDefineDatabaseImplementsRequiredInterfaces(): void + { + $attribute = new DefineDatabase('defineMigrations'); + + $this->assertInstanceOf(Actionable::class, $attribute); + $this->assertInstanceOf(BeforeEach::class, $attribute); + $this->assertInstanceOf(AfterEach::class, $attribute); + } + + public function testDefineDatabaseDeferredExecution(): void + { + $attribute = new DefineDatabase('defineMigrations', defer: true); + $called = false; + + $action = function () use (&$called) { + $called = true; + }; + + $result = $attribute->handle($this->app, $action); + + $this->assertFalse($called); + $this->assertInstanceOf(Closure::class, $result); + + // Execute the deferred callback + $result(); + $this->assertTrue($called); + } + + public function testDefineDatabaseImmediateExecution(): void + { + $attribute = new DefineDatabase('defineMigrations', defer: false); + $called = false; + + $action = function () use (&$called) { + $called = true; + }; + + $result = $attribute->handle($this->app, $action); + + $this->assertTrue($called); + $this->assertNull($result); + } + + public function testResetRefreshDatabaseStateImplementsLifecycleInterfaces(): void + { + $attribute = new ResetRefreshDatabaseState(); + + $this->assertInstanceOf(BeforeAll::class, $attribute); + $this->assertInstanceOf(AfterAll::class, $attribute); + } + + public function testResetRefreshDatabaseStateResetsState(): void + { + // Set some state + RefreshDatabaseState::$migrated = true; + RefreshDatabaseState::$lazilyRefreshed = true; + RefreshDatabaseState::$inMemoryConnections = ['test']; + + ResetRefreshDatabaseState::run(); + + $this->assertFalse(RefreshDatabaseState::$migrated); + $this->assertFalse(RefreshDatabaseState::$lazilyRefreshed); + $this->assertEmpty(RefreshDatabaseState::$inMemoryConnections); + } + + public function testWithMigrationImplementsInvokable(): void + { + $attribute = new WithMigration('/path/to/migrations'); + + $this->assertInstanceOf(Invokable::class, $attribute); + $this->assertSame(['/path/to/migrations'], $attribute->paths); + } + + public function testWithMigrationMultiplePaths(): void + { + $attribute = new WithMigration('/path/one', '/path/two'); + + $this->assertSame(['/path/one', '/path/two'], $attribute->paths); + } + + public function testRequiresEnvImplementsActionable(): void + { + $attribute = new RequiresEnv('SOME_VAR'); + + $this->assertInstanceOf(Actionable::class, $attribute); + $this->assertSame('SOME_VAR', $attribute->key); + } + + public function testDefineImplementsResolvable(): void + { + $attribute = new Define('env', 'setupEnv'); + + $this->assertInstanceOf(Resolvable::class, $attribute); + $this->assertSame('env', $attribute->group); + $this->assertSame('setupEnv', $attribute->method); + } + + public function testDefineResolvesToDefineEnvironment(): void + { + $attribute = new Define('env', 'setupEnv'); + $resolved = $attribute->resolve(); + + $this->assertInstanceOf(DefineEnvironment::class, $resolved); + $this->assertSame('setupEnv', $resolved->method); + } + + public function testDefineResolvesToDefineDatabase(): void + { + $attribute = new Define('db', 'setupDb'); + $resolved = $attribute->resolve(); + + $this->assertInstanceOf(DefineDatabase::class, $resolved); + $this->assertSame('setupDb', $resolved->method); + } + + public function testDefineResolvesToDefineRoute(): void + { + $attribute = new Define('route', 'setupRoutes'); + $resolved = $attribute->resolve(); + + $this->assertInstanceOf(DefineRoute::class, $resolved); + $this->assertSame('setupRoutes', $resolved->method); + } + + public function testDefineReturnsNullForUnknownGroup(): void + { + $attribute = new Define('unknown', 'someMethod'); + $resolved = $attribute->resolve(); + + $this->assertNull($resolved); + } + + public function testDefineGroupIsCaseInsensitive(): void + { + $envUpper = new Define('ENV', 'method'); + $envMixed = new Define('Env', 'method'); + + $this->assertInstanceOf(DefineEnvironment::class, $envUpper->resolve()); + $this->assertInstanceOf(DefineEnvironment::class, $envMixed->resolve()); + } + + public function testAttributesHaveCorrectTargets(): void + { + $this->assertAttributeHasTargets(DefineEnvironment::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(WithConfig::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(DefineRoute::class, ['TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(DefineDatabase::class, ['TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(ResetRefreshDatabaseState::class, ['TARGET_CLASS']); + $this->assertAttributeHasTargets(WithMigration::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(RequiresEnv::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(Define::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + } + + private function assertAttributeHasTargets(string $class, array $expectedTargets): void + { + $reflection = new ReflectionClass($class); + $attributes = $reflection->getAttributes(Attribute::class); + + $this->assertNotEmpty($attributes, "Class {$class} should have #[Attribute]"); + + $attributeInstance = $attributes[0]->newInstance(); + $flags = $attributeInstance->flags; + + foreach ($expectedTargets as $target) { + $constant = constant("Attribute::{$target}"); + $this->assertTrue( + ($flags & $constant) !== 0, + "Class {$class} should have {$target} flag" + ); + } + } +} diff --git a/tests/Foundation/Testing/Attributes/RequiresDatabaseTest.php b/tests/Foundation/Testing/Attributes/RequiresDatabaseTest.php new file mode 100644 index 000000000..bfa67833a --- /dev/null +++ b/tests/Foundation/Testing/Attributes/RequiresDatabaseTest.php @@ -0,0 +1,116 @@ +handle($this->app, $action); + + // Default connection is sqlite, so pgsql requirement should skip + $this->assertTrue($skipped); + $this->assertStringContainsString('pgsql', $skipMessage); + } + + public function testDoesNotSkipWhenDriverMatches(): void + { + $attribute = new RequiresDatabase('sqlite'); + + $skipped = false; + + $action = function (string $method, array $params) use (&$skipped): void { + if ($method === 'markTestSkipped') { + $skipped = true; + } + }; + + $attribute->handle($this->app, $action); + + // Default connection is sqlite, so it should not skip + $this->assertFalse($skipped); + } + + public function testAcceptsArrayOfDrivers(): void + { + $attribute = new RequiresDatabase(['sqlite', 'mysql'], connection: 'default'); + + $skipped = false; + + $action = function (string $method, array $params) use (&$skipped): void { + if ($method === 'markTestSkipped') { + $skipped = true; + } + }; + + $attribute->handle($this->app, $action); + + // sqlite is in the array, should not skip + $this->assertFalse($skipped); + } + + public function testSkipsWhenDriverNotInArray(): void + { + $attribute = new RequiresDatabase(['pgsql', 'mysql'], connection: 'default'); + + $skipped = false; + $skipMessage = null; + + $action = function (string $method, array $params) use (&$skipped, &$skipMessage): void { + if ($method === 'markTestSkipped') { + $skipped = true; + $skipMessage = $params[0] ?? ''; + } + }; + + $attribute->handle($this->app, $action); + + // sqlite is not in [pgsql, mysql], should skip + $this->assertTrue($skipped); + $this->assertStringContainsString('pgsql/mysql', $skipMessage); + } + + public function testThrowsWhenArrayWithDefaultTrue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to validate default connection when given an array of database drivers'); + + new RequiresDatabase(['sqlite', 'pgsql'], default: true); + } + + public function testDefaultIsTrueWhenNoConnectionSpecified(): void + { + $attribute = new RequiresDatabase('sqlite'); + + $this->assertTrue($attribute->default); + } + + public function testDefaultIsNullWhenConnectionSpecified(): void + { + $attribute = new RequiresDatabase('sqlite', connection: 'testing'); + + $this->assertNull($attribute->default); + } +} diff --git a/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php b/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php new file mode 100644 index 000000000..0e90c411a --- /dev/null +++ b/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php @@ -0,0 +1,85 @@ + $attr['key'] === WithConfig::class + ); + + $this->assertCount(2, $withConfigAttributes); + + // Extract the config keys to verify both are present + $configKeys = array_map( + fn ($attr) => $attr['instance']->key, + $withConfigAttributes + ); + + $this->assertContains('testing.parent_class', $configKeys); + $this->assertContains('testing.child_class', $configKeys); + } + + public function testParentAttributeIsExecutedThroughLifecycle(): void + { + // The parent's #[WithConfig('testing.parent_class', 'parent_value')] should be applied + $this->assertSame( + 'parent_value', + $this->app->get('config')->get('testing.parent_class') + ); + } + + public function testChildAttributeIsExecutedThroughLifecycle(): void + { + // The child's #[WithConfig('testing.child_class', 'child_value')] should be applied + $this->assertSame( + 'child_value', + $this->app->get('config')->get('testing.child_class') + ); + } + + public function testParentAttributesAreAppliedBeforeChildAttributes(): void + { + // Parent attributes come first in the array (prepended during recursion) + $attributes = AttributeParser::forClass(static::class); + + $withConfigAttributes = array_values(array_filter( + $attributes, + fn ($attr) => $attr['key'] === WithConfig::class + )); + + // Parent should be first + $this->assertSame('testing.parent_class', $withConfigAttributes[0]['instance']->key); + // Child should be second + $this->assertSame('testing.child_class', $withConfigAttributes[1]['instance']->key); + } +} + +/** + * Abstract parent test case with class-level attributes for inheritance testing. + * + * @internal + */ +#[WithConfig('testing.parent_class', 'parent_value')] +abstract class AbstractParentTestCase extends TestCase +{ +} diff --git a/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php b/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php new file mode 100644 index 000000000..24742d730 --- /dev/null +++ b/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php @@ -0,0 +1,60 @@ +get('config')->set('testing.deferred_executed', true); + } + + #[DefineDatabase('defineDatabaseSetup', defer: true)] + public function testDeferredDefineDatabaseAttributeIsExecuted(): void + { + // The DefineDatabase attribute with defer: true should have its method called + // during the setUp lifecycle, even though execution is deferred + $this->assertTrue( + static::$deferredMethodWasCalled, + 'Deferred DefineDatabase method should be called during setUp' + ); + $this->assertTrue( + $this->app->get('config')->get('testing.deferred_executed', false), + 'Deferred DefineDatabase should have set config value' + ); + } + + #[DefineDatabase('defineDatabaseSetup', defer: false)] + public function testImmediateDefineDatabaseAttributeIsExecuted(): void + { + // The DefineDatabase attribute with defer: false should execute immediately + $this->assertTrue( + static::$deferredMethodWasCalled, + 'Immediate DefineDatabase method should be called during setUp' + ); + $this->assertTrue( + $this->app->get('config')->get('testing.deferred_executed', false), + 'Immediate DefineDatabase should have set config value' + ); + } +} diff --git a/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php b/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php new file mode 100644 index 000000000..f0292bfd2 --- /dev/null +++ b/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php @@ -0,0 +1,48 @@ +defineEnvironmentCalled = true; + $this->passedApp = $app; + + // Set a config value to verify it takes effect before providers boot + $app->get('config')->set('testing.define_environment_test', 'configured'); + } + + public function testDefineEnvironmentIsCalledDuringSetUp(): void + { + $this->assertTrue($this->defineEnvironmentCalled); + } + + public function testAppInstanceIsPassed(): void + { + $this->assertNotNull($this->passedApp); + $this->assertInstanceOf(ApplicationContract::class, $this->passedApp); + $this->assertSame($this->app, $this->passedApp); + } + + public function testConfigChangesAreApplied(): void + { + $this->assertSame( + 'configured', + $this->app->get('config')->get('testing.define_environment_test') + ); + } +} diff --git a/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php b/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php new file mode 100644 index 000000000..a13d21588 --- /dev/null +++ b/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php @@ -0,0 +1,68 @@ +get('config')->set('testing.method_env', 'method_value'); + } + + public function testParseTestMethodAttributesReturnsCollection(): void + { + $result = $this->parseTestMethodAttributes($this->app, WithConfig::class); + + $this->assertInstanceOf(FeaturesCollection::class, $result); + } + + #[WithConfig('testing.method_attribute', 'test_value')] + public function testParseTestMethodAttributesHandlesInvokable(): void + { + // Parse WithConfig attribute which is Invokable + $this->parseTestMethodAttributes($this->app, WithConfig::class); + + // The attribute should have set the config value + $this->assertSame('test_value', $this->app->get('config')->get('testing.method_attribute')); + } + + #[DefineEnvironment('defineConfigEnv')] + public function testParseTestMethodAttributesHandlesActionable(): void + { + // Parse DefineEnvironment attribute which is Actionable + $this->parseTestMethodAttributes($this->app, DefineEnvironment::class); + + // The attribute should have called the method which set the config value + $this->assertSame('method_value', $this->app->get('config')->get('testing.method_env')); + } + + public function testParseTestMethodAttributesReturnsEmptyCollectionForNoMatch(): void + { + $result = $this->parseTestMethodAttributes($this->app, DefineEnvironment::class); + + $this->assertInstanceOf(FeaturesCollection::class, $result); + $this->assertTrue($result->isEmpty()); + } + + #[WithConfig('testing.multi_one', 'one')] + #[WithConfig('testing.multi_two', 'two')] + public function testParseTestMethodAttributesHandlesMultipleAttributes(): void + { + $this->parseTestMethodAttributes($this->app, WithConfig::class); + + $this->assertSame('one', $this->app->get('config')->get('testing.multi_one')); + $this->assertSame('two', $this->app->get('config')->get('testing.multi_two')); + } +} diff --git a/tests/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php b/tests/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php index d741015ac..3b9397d26 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php @@ -6,9 +6,9 @@ use Hyperf\Context\Context; use Hyperf\Contract\ConfigInterface; -use Hypervel\Auth\Contracts\Authenticatable as UserContract; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; -use Hypervel\Auth\Contracts\Guard; +use Hypervel\Contracts\Auth\Authenticatable as UserContract; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; +use Hypervel\Contracts\Auth\Guard; use Hypervel\Foundation\Testing\Concerns\InteractsWithAuthentication; use Hypervel\Testbench\TestCase; use Mockery; diff --git a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php index 6d91fe0f8..a74b2ffdf 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php @@ -5,13 +5,11 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\Model\FactoryBuilder; -use Hyperf\Testing\ModelFactory; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Support\Collection; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Foundation\Stubs\User; use ReflectionClass; -use Workbench\App\Models\User; /** * @internal @@ -23,47 +21,56 @@ class InteractsWithDatabaseTest extends TestCase protected bool $migrateRefresh = true; + protected function migrateFreshUsing(): array + { + return [ + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => dirname(__DIR__, 2) . '/migrations', + ]; + } + public function testAssertDatabaseHas() { - $user = $this->factory(User::class)->create(); + $user = User::factory()->create(); - $this->assertDatabaseHas('users', [ + $this->assertDatabaseHas('foundation_test_users', [ 'id' => $user->id, ]); } public function testAssertDatabaseMissing() { - $this->assertDatabaseMissing('users', [ + $this->assertDatabaseMissing('foundation_test_users', [ 'id' => 1, ]); } public function testAssertDatabaseCount() { - $this->assertDatabaseCount('users', 0); + $this->assertDatabaseCount('foundation_test_users', 0); - $this->factory(User::class)->create(); + User::factory()->create(); - $this->assertDatabaseCount('users', 1); + $this->assertDatabaseCount('foundation_test_users', 1); } public function testAssertDatabaseEmpty() { - $this->assertDatabaseEmpty('users'); + $this->assertDatabaseEmpty('foundation_test_users'); } public function testAssertModelExists() { - $user = $this->factory(User::class)->create(); + $user = User::factory()->create(); $this->assertModelExists($user); } public function testAssertModelMissing() { - $user = $this->factory(User::class)->create(); - $user->id = 2; + $user = User::factory()->create(); + $user->id = 999; $this->assertModelMissing($user); } @@ -74,11 +81,16 @@ public function testFactoryUsesConfiguredFakerLocale() $this->app->get(ConfigInterface::class) ->set('app.faker_locale', $locale); - $factory = $this->factory(User::class); + $factory = User::factory(); + // Use reflection to access the protected $faker property $reflectedClass = new ReflectionClass($factory); $fakerProperty = $reflectedClass->getProperty('faker'); $fakerProperty->setAccessible(true); + + // Trigger faker initialization by calling make() + $factory->make(); + /** @var \Faker\Generator $faker */ $faker = $fakerProperty->getValue($factory); $providerClasses = array_map(fn ($provider) => get_class($provider), $faker->getProviders()); @@ -88,10 +100,4 @@ public function testFactoryUsesConfiguredFakerLocale() "Expected one of the Faker providers to contain the locale '{$locale}', but none did." ); } - - protected function factory(string $class, mixed ...$arguments): FactoryBuilder - { - return $this->app->get(ModelFactory::class) - ->factory($class, ...$arguments); - } } diff --git a/tests/Foundation/Testing/Concerns/InteractsWithSessionTest.php b/tests/Foundation/Testing/Concerns/InteractsWithSessionTest.php index af7dcb560..d96142c5a 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithSessionTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithSessionTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; use Hyperf\Contract\ConfigInterface; -use Hypervel\Session\Contracts\Session; +use Hypervel\Contracts\Session\Session; use Hypervel\Testbench\TestCase; /** diff --git a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php new file mode 100644 index 000000000..460862c41 --- /dev/null +++ b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php @@ -0,0 +1,150 @@ +assertTrue(static::usesTestingConcern(HandlesAttributes::class)); + $this->assertTrue(static::usesTestingConcern(InteractsWithTestCase::class)); + } + + public function testUsesTestingConcernReturnsFalseForUnusedTrait(): void + { + $this->assertFalse(static::usesTestingConcern('NonExistentTrait')); + } + + public function testCachedUsesForTestCaseReturnsTraits(): void + { + $uses = static::cachedUsesForTestCase(); + + $this->assertIsArray($uses); + $this->assertArrayHasKey(HandlesAttributes::class, $uses); + $this->assertArrayHasKey(InteractsWithTestCase::class, $uses); + } + + public function testResolvePhpUnitAttributesReturnsCollection(): void + { + $attributes = $this->resolvePhpUnitAttributes(); + + $this->assertInstanceOf(Collection::class, $attributes); + } + + #[WithConfig('testing.method_level', 'method_value')] + public function testResolvePhpUnitAttributesMergesClassAndMethodAttributes(): void + { + $attributes = $this->resolvePhpUnitAttributes(); + + // Should have WithConfig from both class and method level + $this->assertTrue($attributes->has(WithConfig::class)); + + $withConfigInstances = $attributes->get(WithConfig::class); + $this->assertCount(2, $withConfigInstances); + } + + public function testClassLevelAttributeIsApplied(): void + { + // The WithConfig attribute at class level should be applied + $this->assertSame('class_value', $this->app->get('config')->get('testing.class_level')); + } + + public function testUsesTestingFeatureAddsAttribute(): void + { + // Add a testing feature programmatically at method level so it doesn't + // persist to other tests in this class + static::usesTestingFeature( + new WithConfig('testing.programmatic', 'added'), + Attribute::TARGET_METHOD + ); + + // Re-resolve attributes to include the programmatically added one + $attributes = $this->resolvePhpUnitAttributes(); + + $this->assertTrue($attributes->has(WithConfig::class)); + } + + public function testDefineMetaAttributeIsResolvedByAttributeParser(): void + { + // Test that AttributeParser resolves #[Define('env', 'method')] to DefineEnvironment + $attributes = AttributeParser::forMethod( + DefineMetaAttributeTestCase::class, + 'testWithDefineAttribute' + ); + + // Should have one attribute, resolved from Define to DefineEnvironment + $this->assertCount(1, $attributes); + $this->assertSame(DefineEnvironment::class, $attributes[0]['key']); + $this->assertInstanceOf(DefineEnvironment::class, $attributes[0]['instance']); + $this->assertSame('setupDefineEnv', $attributes[0]['instance']->method); + } + + #[Define('env', 'setupDefineEnvForExecution')] + public function testDefineMetaAttributeIsExecutedThroughLifecycle(): void + { + // The #[Define('env', 'setupDefineEnvForExecution')] attribute should have been + // resolved to DefineEnvironment and executed during setUp, calling our method + $this->assertSame( + 'define_env_executed', + $this->app->get('config')->get('testing.define_meta_attribute') + ); + } + + protected function setupDefineEnvForExecution($app): void + { + $app->get('config')->set('testing.define_meta_attribute', 'define_env_executed'); + } + + public function testResolvePhpUnitAttributesReturnsCollectionOfCollections(): void + { + $attributes = $this->resolvePhpUnitAttributes(); + + $this->assertInstanceOf(Collection::class, $attributes); + + // Each value should be a Collection, not an array + $attributes->each(function ($value, $key) { + $this->assertInstanceOf( + Collection::class, + $value, + "Value for key {$key} should be a Collection, not " . gettype($value) + ); + }); + } +} + +/** + * Test fixture for Define meta-attribute parsing. + * + * @internal + * @coversNothing + */ +class DefineMetaAttributeTestCase extends TestCase +{ + #[Define('env', 'setupDefineEnv')] + public function testWithDefineAttribute(): void + { + // This method exists just to have the attribute parsed + } + + protected function setupDefineEnv($app): void + { + // Method that would be called + } +} diff --git a/tests/Foundation/Testing/DatabaseMigrationsTest.php b/tests/Foundation/Testing/DatabaseMigrationsTest.php index 1346c0451..c2098d41e 100644 --- a/tests/Foundation/Testing/DatabaseMigrationsTest.php +++ b/tests/Foundation/Testing/DatabaseMigrationsTest.php @@ -6,7 +6,7 @@ use Hyperf\Config\Config; use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Foundation\Testing\Concerns\InteractsWithConsole; use Hypervel\Foundation\Testing\DatabaseMigrations; use Hypervel\Testbench\TestCase; diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index e4bfb4c0e..a9c4331f3 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -6,9 +6,9 @@ use Hyperf\Config\Config; use Hyperf\Contract\ConfigInterface; -use Hyperf\Contract\ConnectionInterface; -use Hyperf\DbConnection\Db; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\DatabaseManager; use Hypervel\Foundation\Testing\Concerns\InteractsWithConsole; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Foundation\Testing\RefreshDatabaseState; @@ -66,7 +66,7 @@ public function testRefreshTestDatabaseDefault() $this->app = $this->getApplication([ ConfigInterface::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, - Db::class => fn () => $this->getMockedDatabase(), + DatabaseManager::class => fn () => $this->getMockedDatabase(), ]); $this->refreshTestDatabase(); @@ -87,7 +87,7 @@ public function testRefreshTestDatabaseWithDropViewsOption() $this->app = $this->getApplication([ ConfigInterface::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, - Db::class => fn () => $this->getMockedDatabase(), + DatabaseManager::class => fn () => $this->getMockedDatabase(), ]); $this->refreshTestDatabase(); @@ -108,7 +108,7 @@ public function testRefreshTestDatabaseWithSeedOption() $this->app = $this->getApplication([ ConfigInterface::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, - Db::class => fn () => $this->getMockedDatabase(), + DatabaseManager::class => fn () => $this->getMockedDatabase(), ]); $this->refreshTestDatabase(); @@ -129,7 +129,7 @@ public function testRefreshTestDatabaseWithSeederOption() $this->app = $this->getApplication([ ConfigInterface::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, - Db::class => fn () => $this->getMockedDatabase(), + DatabaseManager::class => fn () => $this->getMockedDatabase(), ]); $this->refreshTestDatabase(); @@ -144,7 +144,7 @@ protected function getConfig(array $config = []): Config ], $config)); } - protected function getMockedDatabase(): Db + protected function getMockedDatabase(): DatabaseManager { $connection = m::mock(ConnectionInterface::class); $connection->shouldReceive('getEventDispatcher') @@ -167,7 +167,7 @@ protected function getMockedDatabase(): Db ->once() ->andReturn($pdo); - $db = m::mock(Db::class); + $db = m::mock(DatabaseManager::class); $db->shouldReceive('connection') ->twice() ->with(null) diff --git a/tests/Foundation/migrations/2024_01_01_000000_create_foundation_test_users_table.php b/tests/Foundation/migrations/2024_01_01_000000_create_foundation_test_users_table.php new file mode 100644 index 000000000..77bd95525 --- /dev/null +++ b/tests/Foundation/migrations/2024_01_01_000000_create_foundation_test_users_table.php @@ -0,0 +1,19 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamps(); + }); + } +}; diff --git a/tests/Horizon/Feature/AutoScalerTest.php b/tests/Horizon/Feature/AutoScalerTest.php index 6612f3fea..9bf8aff5e 100644 --- a/tests/Horizon/Feature/AutoScalerTest.php +++ b/tests/Horizon/Feature/AutoScalerTest.php @@ -4,13 +4,13 @@ namespace Hypervel\Tests\Horizon\Feature; +use Hypervel\Contracts\Queue\Factory as QueueFactory; use Hypervel\Horizon\AutoScaler; use Hypervel\Horizon\Contracts\MetricsRepository; use Hypervel\Horizon\RedisQueue; use Hypervel\Horizon\Supervisor; use Hypervel\Horizon\SupervisorOptions; use Hypervel\Horizon\SystemProcessCounter; -use Hypervel\Queue\Contracts\Factory as QueueFactory; use Hypervel\Tests\Horizon\IntegrationTestCase; use Mockery; diff --git a/tests/Horizon/Feature/Exceptions/DontReportException.php b/tests/Horizon/Feature/Exceptions/DontReportException.php index 7c42fb3d8..4a7cf45de 100644 --- a/tests/Horizon/Feature/Exceptions/DontReportException.php +++ b/tests/Horizon/Feature/Exceptions/DontReportException.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Horizon\Feature\Exceptions; use Exception; -use Hypervel\Foundation\Exceptions\Contracts\ShouldntReport; +use Hypervel\Contracts\Debug\ShouldntReport; class DontReportException extends Exception implements ShouldntReport { diff --git a/tests/Horizon/Feature/Fixtures/FakeListenerSilenced.php b/tests/Horizon/Feature/Fixtures/FakeListenerSilenced.php index 4e7178915..14ee29772 100644 --- a/tests/Horizon/Feature/Fixtures/FakeListenerSilenced.php +++ b/tests/Horizon/Feature/Fixtures/FakeListenerSilenced.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Horizon\Feature\Fixtures; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Horizon\Contracts\Silenced; class FakeListenerSilenced implements Silenced diff --git a/tests/Horizon/Feature/Fixtures/FakeListenerWithProperties.php b/tests/Horizon/Feature/Fixtures/FakeListenerWithProperties.php index 75da81c42..e41c6a66b 100644 --- a/tests/Horizon/Feature/Fixtures/FakeListenerWithProperties.php +++ b/tests/Horizon/Feature/Fixtures/FakeListenerWithProperties.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Horizon\Feature\Fixtures; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; class FakeListenerWithProperties { diff --git a/tests/Horizon/Feature/Fixtures/SilencedMailable.php b/tests/Horizon/Feature/Fixtures/SilencedMailable.php index f28e23295..cf7e0e7aa 100644 --- a/tests/Horizon/Feature/Fixtures/SilencedMailable.php +++ b/tests/Horizon/Feature/Fixtures/SilencedMailable.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Horizon\Feature\Fixtures; -use Hypervel\Mail\Contracts\Mailable; +use Hypervel\Contracts\Mail\Mailable; interface SilencedMailable extends Mailable { diff --git a/tests/Horizon/Feature/Listeners/StoreTagsForFailedTest.php b/tests/Horizon/Feature/Listeners/StoreTagsForFailedTest.php index 589195c8c..4ad6e8b63 100644 --- a/tests/Horizon/Feature/Listeners/StoreTagsForFailedTest.php +++ b/tests/Horizon/Feature/Listeners/StoreTagsForFailedTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Horizon\Feature\Listeners; use Exception; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Horizon\Contracts\TagRepository; use Hypervel\Horizon\Events\JobFailed; use Hypervel\Queue\Jobs\Job; @@ -21,8 +21,6 @@ class StoreTagsForFailedTest extends IntegrationTestCase protected function tearDown(): void { parent::tearDown(); - - m::close(); } public function testTemporaryFailedJobShouldBeDeletedWhenTheMainJobIsDeleted(): void diff --git a/tests/Horizon/Feature/RedisPayloadTest.php b/tests/Horizon/Feature/RedisPayloadTest.php index 35678bf12..d841a7067 100644 --- a/tests/Horizon/Feature/RedisPayloadTest.php +++ b/tests/Horizon/Feature/RedisPayloadTest.php @@ -5,10 +5,10 @@ namespace Hypervel\Tests\Horizon\Feature; use Hypervel\Broadcasting\BroadcastEvent; +use Hypervel\Contracts\Mail\Mailable; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Horizon\Contracts\Silenced; use Hypervel\Horizon\JobPayload; -use Hypervel\Mail\Contracts\Mailable; use Hypervel\Mail\SendQueuedMailable; use Hypervel\Notifications\SendQueuedNotifications; use Hypervel\Tests\Horizon\Feature\Fixtures\FakeEvent; diff --git a/tests/Horizon/Feature/SupervisorCommandTest.php b/tests/Horizon/Feature/SupervisorCommandTest.php index 9826a8bc1..c8f737326 100644 --- a/tests/Horizon/Feature/SupervisorCommandTest.php +++ b/tests/Horizon/Feature/SupervisorCommandTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Horizon\Feature; -use Hypervel\Foundation\Console\Contracts\Kernel; +use Hypervel\Contracts\Console\Kernel; use Hypervel\Horizon\Console\SupervisorCommand; use Hypervel\Horizon\SupervisorFactory; use Hypervel\Tests\Horizon\Feature\Fixtures\FakeSupervisorFactory; diff --git a/tests/Horizon/Feature/SupervisorTest.php b/tests/Horizon/Feature/SupervisorTest.php index 14c426bb2..65cecfaec 100644 --- a/tests/Horizon/Feature/SupervisorTest.php +++ b/tests/Horizon/Feature/SupervisorTest.php @@ -6,7 +6,7 @@ use Carbon\CarbonImmutable; use Exception; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Horizon\AutoScaler; use Hypervel\Horizon\Contracts\HorizonCommandQueue; use Hypervel\Horizon\Contracts\JobRepository; diff --git a/tests/Horizon/Feature/WaitTimeCalculatorTest.php b/tests/Horizon/Feature/WaitTimeCalculatorTest.php index d6370df4a..ca68a56be 100644 --- a/tests/Horizon/Feature/WaitTimeCalculatorTest.php +++ b/tests/Horizon/Feature/WaitTimeCalculatorTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Horizon\Feature; +use Hypervel\Contracts\Queue\Factory as QueueFactory; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Horizon\Contracts\MetricsRepository; use Hypervel\Horizon\Contracts\SupervisorRepository; use Hypervel\Horizon\WaitTimeCalculator; -use Hypervel\Queue\Contracts\Factory as QueueFactory; -use Hypervel\Queue\Contracts\Queue; use Hypervel\Tests\Horizon\IntegrationTestCase; use Mockery; diff --git a/tests/Horizon/UnitTestCase.php b/tests/Horizon/UnitTestCase.php index d0121c5de..ccfb7aeaa 100644 --- a/tests/Horizon/UnitTestCase.php +++ b/tests/Horizon/UnitTestCase.php @@ -4,13 +4,8 @@ namespace Hypervel\Tests\Horizon; -use Mockery; use PHPUnit\Framework\TestCase; abstract class UnitTestCase extends TestCase { - protected function tearDown(): void - { - Mockery::close(); - } } diff --git a/tests/Horizon/worker.php b/tests/Horizon/worker.php index f3da1eb10..9e8506f2b 100644 --- a/tests/Horizon/worker.php +++ b/tests/Horizon/worker.php @@ -9,10 +9,10 @@ use Hyperf\Contract\ConfigInterface; use Hyperf\Coordinator\Constants; use Hyperf\Coordinator\CoordinatorManager; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Foundation\Application; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; use Hypervel\Foundation\Console\Kernel as ConsoleKernel; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Horizon\HorizonServiceProvider; use Hypervel\Queue\Worker; use Hypervel\Queue\WorkerOptions; diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index c11382601..ac440f021 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -5,21 +5,21 @@ namespace Hypervel\Tests\Http; use Carbon\Carbon; -use Hyperf\Collection\Collection; use Hyperf\Context\ApplicationContext; use Hyperf\Context\Context; use Hyperf\HttpMessage\Upload\UploadedFile; use Hyperf\HttpMessage\Uri\Uri as HyperfUri; use Hyperf\HttpServer\Request as HyperfRequest; use Hyperf\HttpServer\Router\Dispatched; -use Hyperf\Stringable\Stringable; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Session\Session as SessionContract; +use Hypervel\Contracts\Validation\Factory as ValidatorFactoryContract; use Hypervel\Http\DispatchedRoute; use Hypervel\Http\Request; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; use Hypervel\Router\RouteHandler; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Support\Collection; +use Hypervel\Support\Stringable; use Hypervel\Support\Uri; -use Hypervel\Validation\Contracts\Factory as ValidatorFactoryContract; use Mockery; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -35,7 +35,6 @@ class RequestTest extends TestCase { protected function tearDown(): void { - Mockery::close(); Context::destroy(ServerRequestInterface::class); Context::destroy('http.request.parsedData'); Context::destroy(HyperfRequest::class . '.properties.requestUri'); diff --git a/tests/Http/ResponseTest.php b/tests/Http/ResponseTest.php index db29af6a3..a2335b13a 100644 --- a/tests/Http/ResponseTest.php +++ b/tests/Http/ResponseTest.php @@ -6,12 +6,12 @@ use Hyperf\Context\ApplicationContext; use Hyperf\Context\Context; -use Hyperf\Contract\Arrayable; -use Hyperf\Contract\Jsonable; use Hyperf\HttpMessage\Stream\SwooleStream; use Hyperf\HttpServer\Response as HyperfResponse; use Hyperf\Support\Filesystem\Filesystem; use Hyperf\View\RenderInterface; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; use Hypervel\Http\Exceptions\FileNotFoundException; use Hypervel\Http\Response; use Hypervel\HttpMessage\Exceptions\RangeNotSatisfiableHttpException; @@ -21,7 +21,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use RuntimeException; -use Stringable; use Swow\Psr7\Message\ResponsePlusInterface; use Swow\Psr7\Message\ServerRequestPlusInterface; @@ -33,7 +32,6 @@ class ResponseTest extends TestCase { protected function tearDown(): void { - Mockery::close(); Context::destroy(ResponseInterface::class); Context::destroy(Response::RANGE_HEADERS_CONTEXT); Context::destroy(ServerRequestInterface::class); @@ -75,8 +73,8 @@ public function toArray(): array $this->assertEquals('application/json', $result->getHeaderLine('content-type')); // Test with Jsonable content - $jsonable = new class implements Stringable, Jsonable { - public function __toString(): string + $jsonable = new class implements Jsonable { + public function toJson(int $options = 0): string { return '{"baz":"qux"}'; } diff --git a/tests/HttpClient/HttpClientTest.php b/tests/HttpClient/HttpClientTest.php index 9849bef4d..3d84aaf07 100644 --- a/tests/HttpClient/HttpClientTest.php +++ b/tests/HttpClient/HttpClientTest.php @@ -13,11 +13,9 @@ use GuzzleHttp\TransferStats; use Hyperf\Config\Config; use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\Arrayable; use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\ContainerInterface; -use Hyperf\Stringable\Str; -use Hyperf\Stringable\Stringable; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Http\Response as HttpResponse; use Hypervel\HttpClient\ConnectionException; use Hypervel\HttpClient\Events\RequestSending; @@ -35,6 +33,8 @@ use Hypervel\Support\Collection; use Hypervel\Support\Fluent; use Hypervel\Support\Sleep; +use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Hypervel\Tests\TestCase; use JsonSerializable; use Mockery as m; @@ -69,7 +69,6 @@ protected function setUp(): void protected function tearDown(): void { - m::close(); parent::tearDown(); } diff --git a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php new file mode 100644 index 000000000..5804430e0 --- /dev/null +++ b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php @@ -0,0 +1,354 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + + protected function setUp(): void + { + parent::setUp(); + + UnguardedTestUser::$eventLog = []; + Model::reguard(); + } + + public function testUnguardedDisablesGuardingWithinCallback(): void + { + $this->assertFalse(Model::isUnguarded()); + + Model::unguarded(function () { + $this->assertTrue(Model::isUnguarded()); + }); + + $this->assertFalse(Model::isUnguarded()); + } + + public function testUnguardedRestoresStateAfterException(): void + { + $this->assertFalse(Model::isUnguarded()); + + try { + Model::unguarded(function () { + $this->assertTrue(Model::isUnguarded()); + throw new RuntimeException('Test exception'); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertFalse(Model::isUnguarded()); + } + + public function testUnguardedSupportsNesting(): void + { + $this->assertFalse(Model::isUnguarded()); + + Model::unguarded(function () { + $this->assertTrue(Model::isUnguarded()); + + Model::unguarded(function () { + $this->assertTrue(Model::isUnguarded()); + }); + + $this->assertTrue(Model::isUnguarded()); + }); + + $this->assertFalse(Model::isUnguarded()); + } + + public function testUnguardedIsCoroutineIsolated(): void + { + $results = []; + $channel = new Channel(2); + $waiter = new WaitGroup(); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + Model::unguarded(function () use ($channel) { + $channel->push(['coroutine' => 1, 'unguarded' => Model::isUnguarded()]); + usleep(50000); + }); + $waiter->done(); + }); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + usleep(10000); + $channel->push(['coroutine' => 2, 'unguarded' => Model::isUnguarded()]); + $waiter->done(); + }); + + $waiter->wait(); + $channel->close(); + + while (($result = $channel->pop()) !== false) { + $results[$result['coroutine']] = $result['unguarded']; + } + + $this->assertTrue($results[1], 'Coroutine 1 should be unguarded'); + $this->assertFalse($results[2], 'Coroutine 2 should NOT be unguarded (isolated context)'); + } + + public function testUsingConnectionChangesDefaultWithinCallback(): void + { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + $originalDefault = $manager->getDefaultConnection(); + + $testConnection = 'sqlite'; + + $manager->usingConnection($testConnection, function () use ($manager, $testConnection) { + $this->assertSame($testConnection, $manager->getDefaultConnection()); + }); + + $this->assertSame($originalDefault, $manager->getDefaultConnection()); + } + + public function testUsingConnectionRestoresStateAfterException(): void + { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + $originalDefault = $manager->getDefaultConnection(); + $testConnection = 'sqlite'; + + try { + $manager->usingConnection($testConnection, function () use ($manager, $testConnection) { + $this->assertSame($testConnection, $manager->getDefaultConnection()); + throw new RuntimeException('Test exception'); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertSame($originalDefault, $manager->getDefaultConnection()); + } + + public function testUsingConnectionIsCoroutineIsolated(): void + { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + $originalDefault = $manager->getDefaultConnection(); + $testConnection = 'sqlite'; + + $results = []; + $channel = new Channel(2); + $waiter = new WaitGroup(); + + $waiter->add(1); + go(function () use ($channel, $waiter, $manager, $testConnection) { + $manager->usingConnection($testConnection, function () use ($channel, $manager) { + $channel->push(['coroutine' => 1, 'connection' => $manager->getDefaultConnection()]); + usleep(50000); + }); + $waiter->done(); + }); + + $waiter->add(1); + go(function () use ($channel, $waiter, $manager) { + usleep(10000); + $channel->push(['coroutine' => 2, 'connection' => $manager->getDefaultConnection()]); + $waiter->done(); + }); + + $waiter->wait(); + $channel->close(); + + while (($result = $channel->pop()) !== false) { + $results[$result['coroutine']] = $result['connection']; + } + + $this->assertSame($testConnection, $results[1], 'Coroutine 1 should see overridden connection'); + $this->assertSame($originalDefault, $results[2], 'Coroutine 2 should see original connection (isolated)'); + } + + public function testUsingConnectionAffectsDbConnection(): void + { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + $originalDefault = $manager->getDefaultConnection(); + + $connectionBefore = DB::connection(); + $this->assertSame($originalDefault, $connectionBefore->getName()); + + $testConnection = 'sqlite'; + + $manager->usingConnection($testConnection, function () use ($testConnection) { + $connection = DB::connection(); + $this->assertSame( + $testConnection, + $connection->getName(), + 'DB::connection() should return the usingConnection override' + ); + }); + + $connectionAfter = DB::connection(); + $this->assertSame($originalDefault, $connectionAfter->getName()); + } + + public function testUsingConnectionAffectsSchemaConnection(): void + { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + $originalDefault = $manager->getDefaultConnection(); + + $testConnection = 'sqlite'; + + $manager->usingConnection($testConnection, function () use ($testConnection) { + $schemaBuilder = Schema::connection(); + $connectionName = $schemaBuilder->getConnection()->getName(); + + $this->assertSame( + $testConnection, + $connectionName, + 'Schema::connection() should return schema builder for usingConnection override' + ); + }); + } + + public function testUsingConnectionAffectsConnectionResolver(): void + { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + + /** @var ConnectionResolverInterface $resolver */ + $resolver = $this->app->get(ConnectionResolverInterface::class); + + $originalDefault = $manager->getDefaultConnection(); + $testConnection = 'sqlite'; + + $this->assertSame($originalDefault, $resolver->getDefaultConnection()); + + $manager->usingConnection($testConnection, function () use ($resolver, $testConnection) { + $this->assertSame( + $testConnection, + $resolver->getDefaultConnection(), + 'ConnectionResolver::getDefaultConnection() should respect usingConnection override' + ); + + $connection = $resolver->connection(); + $this->assertSame( + $testConnection, + $connection->getName(), + 'ConnectionResolver::connection() should return usingConnection override' + ); + }); + + $this->assertSame($originalDefault, $resolver->getDefaultConnection()); + } + + public function testBeforeExecutingCallbackIsCalled(): void + { + $called = false; + $capturedQuery = null; + + /** @var Connection $connection */ + $connection = DB::connection($this->driver); + $connection->beforeExecuting(function ($query) use (&$called, &$capturedQuery) { + $called = true; + $capturedQuery = $query; + }); + + $connection->select('SELECT 1'); + + $this->assertTrue($called); + $this->assertSame('SELECT 1', $capturedQuery); + } + + public function testClearBeforeExecutingCallbacksExists(): void + { + /** @var Connection $connection */ + $connection = DB::connection($this->driver); + + $called = false; + $connection->beforeExecuting(function () use (&$called) { + $called = true; + }); + + $this->assertTrue(method_exists($connection, 'clearBeforeExecutingCallbacks')); + + $connection->clearBeforeExecutingCallbacks(); + + $connection->select('SELECT 1'); + $this->assertFalse($called); + } + + public function testConnectionTracksErrorCount(): void + { + /** @var Connection $connection */ + $connection = DB::connection($this->driver); + + $this->assertTrue(method_exists($connection, 'getErrorCount')); + + $initialCount = $connection->getErrorCount(); + + try { + $connection->select('SELECT * FROM nonexistent_table_xyz'); + } catch (Throwable) { + // Expected + } + + $this->assertGreaterThan($initialCount, $connection->getErrorCount()); + } + + public function testPooledConnectionHasEventDispatcher(): void + { + /** @var Connection $connection */ + $connection = DB::connection($this->driver); + + $dispatcher = $connection->getEventDispatcher(); + $this->assertNotNull($dispatcher, 'Pooled connection should have event dispatcher configured'); + } + + public function testPooledConnectionHasTransactionManager(): void + { + /** @var Connection $connection */ + $connection = DB::connection($this->driver); + + $manager = $connection->getTransactionManager(); + $this->assertNotNull($manager, 'Pooled connection should have transaction manager configured'); + } +} + +class UnguardedTestUser extends Model +{ + protected ?string $table = 'tmp_users'; + + protected array $fillable = ['name', 'email']; + + public static array $eventLog = []; +} diff --git a/tests/Integration/Database/DatabaseTestCase.php b/tests/Integration/Database/DatabaseTestCase.php new file mode 100644 index 000000000..d8d527a07 --- /dev/null +++ b/tests/Integration/Database/DatabaseTestCase.php @@ -0,0 +1,75 @@ +beforeApplicationDestroyed(function () { + $db = $this->app->get(DatabaseManager::class); + foreach (array_keys($db->getConnections()) as $name) { + $db->purge($name); + } + }); + + parent::setUp(); + } + + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $config = $app->get('config'); + $connection = $config->get('database.default'); + + $this->driver = $config->get("database.connections.{$connection}.driver", 'sqlite'); + } + + /** + * Skip this test if not running on the specified driver. + */ + protected function skipUnlessDriver(string $driver): void + { + if ($this->driver !== $driver) { + $this->markTestSkipped("This test requires the {$driver} database driver."); + } + } + + /** + * Skip this test if running on the specified driver. + */ + protected function skipIfDriver(string $driver): void + { + if ($this->driver === $driver) { + $this->markTestSkipped("This test cannot run on the {$driver} database driver."); + } + } +} diff --git a/tests/Integration/Database/Eloquent/CastsTest.php b/tests/Integration/Database/Eloquent/CastsTest.php new file mode 100644 index 000000000..87e356111 --- /dev/null +++ b/tests/Integration/Database/Eloquent/CastsTest.php @@ -0,0 +1,294 @@ +id(); + $table->string('name'); + $table->integer('age')->nullable(); + $table->decimal('price', 10, 2)->nullable(); + $table->boolean('is_active')->default(false); + $table->json('metadata')->nullable(); + $table->json('settings')->nullable(); + $table->json('tags')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->date('birth_date')->nullable(); + $table->text('content')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + }); + } + + public function testIntegerCast(): void + { + $model = CastModel::create(['name' => 'Test', 'age' => '25']); + + $this->assertIsInt($model->age); + $this->assertSame(25, $model->age); + + $retrieved = CastModel::find($model->id); + $this->assertIsInt($retrieved->age); + } + + public function testFloatCast(): void + { + $model = CastModel::create(['name' => 'Test', 'price' => '19.99']); + + $this->assertIsFloat($model->price); + $this->assertSame(19.99, $model->price); + } + + public function testBooleanCast(): void + { + $model = CastModel::create(['name' => 'Test', 'is_active' => 1]); + + $this->assertIsBool($model->is_active); + $this->assertTrue($model->is_active); + + $model->is_active = 0; + $model->save(); + + $this->assertFalse($model->fresh()->is_active); + } + + public function testArrayCast(): void + { + $metadata = ['key' => 'value', 'nested' => ['a' => 1, 'b' => 2]]; + $model = CastModel::create(['name' => 'Test', 'metadata' => $metadata]); + + $this->assertIsArray($model->metadata); + $this->assertSame('value', $model->metadata['key']); + $this->assertSame(1, $model->metadata['nested']['a']); + + $retrieved = CastModel::find($model->id); + $this->assertIsArray($retrieved->metadata); + $this->assertSame($metadata, $retrieved->metadata); + } + + public function testJsonCastWithNull(): void + { + $model = CastModel::create(['name' => 'Test', 'metadata' => null]); + + $this->assertNull($model->metadata); + + $retrieved = CastModel::find($model->id); + $this->assertNull($retrieved->metadata); + } + + public function testCollectionCast(): void + { + $tags = ['php', 'laravel', 'hypervel']; + $model = CastModel::create(['name' => 'Test', 'tags' => $tags]); + + $this->assertInstanceOf(Collection::class, $model->tags); + $this->assertCount(3, $model->tags); + $this->assertContains('php', $model->tags->toArray()); + + $retrieved = CastModel::find($model->id); + $this->assertInstanceOf(Collection::class, $retrieved->tags); + } + + public function testDatetimeCast(): void + { + $now = Carbon::now(); + $model = CastModel::create(['name' => 'Test', 'published_at' => $now]); + + $this->assertInstanceOf(CarbonInterface::class, $model->published_at); + + $retrieved = CastModel::find($model->id); + $this->assertInstanceOf(CarbonInterface::class, $retrieved->published_at); + $this->assertSame($now->format('Y-m-d H:i:s'), $retrieved->published_at->format('Y-m-d H:i:s')); + } + + public function testDateCast(): void + { + $date = Carbon::parse('1990-05-15'); + $model = CastModel::create(['name' => 'Test', 'birth_date' => $date]); + + $this->assertInstanceOf(CarbonInterface::class, $model->birth_date); + + $retrieved = CastModel::find($model->id); + $this->assertSame('1990-05-15', $retrieved->birth_date->format('Y-m-d')); + } + + public function testDatetimeCastFromString(): void + { + $model = CastModel::create(['name' => 'Test', 'published_at' => '2024-01-15 10:30:00']); + + $this->assertInstanceOf(CarbonInterface::class, $model->published_at); + $this->assertSame('2024-01-15', $model->published_at->format('Y-m-d')); + $this->assertSame('10:30:00', $model->published_at->format('H:i:s')); + } + + public function testTimestampsCast(): void + { + $model = CastModel::create(['name' => 'Test']); + + $this->assertInstanceOf(CarbonInterface::class, $model->created_at); + $this->assertInstanceOf(CarbonInterface::class, $model->updated_at); + } + + public function testEnumCast(): void + { + $model = CastModel::create(['name' => 'Test', 'status' => CastStatus::Active]); + + $this->assertInstanceOf(CastStatus::class, $model->status); + $this->assertSame(CastStatus::Active, $model->status); + + $retrieved = CastModel::find($model->id); + $this->assertInstanceOf(CastStatus::class, $retrieved->status); + $this->assertSame(CastStatus::Active, $retrieved->status); + } + + public function testEnumCastFromString(): void + { + $model = CastModel::create(['name' => 'Test', 'status' => 'pending']); + + $this->assertInstanceOf(CastStatus::class, $model->status); + $this->assertSame(CastStatus::Pending, $model->status); + } + + public function testCastOnUpdate(): void + { + $model = CastModel::create(['name' => 'Test', 'age' => 25]); + + $model->update(['age' => '30']); + + $this->assertIsInt($model->age); + $this->assertSame(30, $model->age); + } + + public function testMassAssignmentWithCasts(): void + { + $model = CastModel::create([ + 'name' => 'Test', + 'age' => '25', + 'price' => '99.99', + 'is_active' => '1', + 'metadata' => ['foo' => 'bar'], + 'published_at' => '2024-01-01 00:00:00', + ]); + + $this->assertIsInt($model->age); + $this->assertIsFloat($model->price); + $this->assertIsBool($model->is_active); + $this->assertIsArray($model->metadata); + $this->assertInstanceOf(CarbonInterface::class, $model->published_at); + } + + public function testArrayObjectCast(): void + { + $settings = ['theme' => 'dark', 'notifications' => true]; + $model = CastModel::create(['name' => 'Test', 'settings' => $settings]); + + $this->assertInstanceOf(ArrayObject::class, $model->settings); + $this->assertSame('dark', $model->settings['theme']); + + $model->settings['theme'] = 'light'; + $model->save(); + + $retrieved = CastModel::find($model->id); + $this->assertSame('light', $retrieved->settings['theme']); + } + + public function testNullableAttributesWithCasts(): void + { + $model = CastModel::create(['name' => 'Test']); + + $this->assertNull($model->age); + $this->assertNull($model->price); + $this->assertNull($model->metadata); + $this->assertNull($model->published_at); + } + + public function testGetOriginalWithCasts(): void + { + $model = CastModel::create(['name' => 'Test', 'age' => 25]); + + $model->age = 30; + + $this->assertSame(30, $model->age); + $this->assertSame(25, $model->getOriginal('age')); + } + + public function testIsDirtyWithCasts(): void + { + $model = CastModel::create(['name' => 'Test', 'age' => 25]); + + $this->assertFalse($model->isDirty('age')); + + $model->age = 30; + + $this->assertTrue($model->isDirty('age')); + } + + public function testWasChangedWithCasts(): void + { + $model = CastModel::create(['name' => 'Test', 'age' => 25]); + + $model->age = 30; + $model->save(); + + $this->assertTrue($model->wasChanged('age')); + $this->assertFalse($model->wasChanged('name')); + } +} + +enum CastStatus: string +{ + case Active = 'active'; + case Inactive = 'inactive'; + case Pending = 'pending'; +} + +class CastModel extends Model +{ + protected ?string $table = 'cast_models'; + + protected array $fillable = [ + 'name', + 'age', + 'price', + 'is_active', + 'metadata', + 'settings', + 'tags', + 'published_at', + 'birth_date', + 'content', + 'status', + ]; + + protected array $casts = [ + 'age' => 'integer', + 'price' => 'float', + 'is_active' => 'boolean', + 'metadata' => 'array', + 'settings' => AsArrayObject::class, + 'tags' => AsCollection::class, + 'published_at' => 'immutable_datetime', + 'birth_date' => 'immutable_date', + 'status' => CastStatus::class, + ]; +} diff --git a/tests/Integration/Database/Eloquent/EventsTest.php b/tests/Integration/Database/Eloquent/EventsTest.php new file mode 100644 index 000000000..e31e6f172 --- /dev/null +++ b/tests/Integration/Database/Eloquent/EventsTest.php @@ -0,0 +1,201 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + + protected function setUp(): void + { + parent::setUp(); + + EventsTestUser::$eventLog = []; + } + + public function testBasicModelCanBeCreatedAndRetrieved(): void + { + $user = EventsTestUser::create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $this->assertInstanceOf(EventsTestUser::class, $user); + $this->assertTrue($user->exists); + $this->assertSame('John Doe', $user->name); + $this->assertSame('john@example.com', $user->email); + + $retrieved = EventsTestUser::find($user->id); + $this->assertNotNull($retrieved); + $this->assertSame('John Doe', $retrieved->name); + } + + public function testCreatingEventIsFired(): void + { + EventsTestUser::creating(function (EventsTestUser $user) { + EventsTestUser::$eventLog[] = 'creating:' . $user->name; + }); + + $user = EventsTestUser::create([ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + ]); + + $this->assertContains('creating:Jane Doe', EventsTestUser::$eventLog); + } + + public function testCreatedEventIsFired(): void + { + EventsTestUser::created(function (EventsTestUser $user) { + EventsTestUser::$eventLog[] = 'created:' . $user->id; + }); + + $user = EventsTestUser::create([ + 'name' => 'Bob Smith', + 'email' => 'bob@example.com', + ]); + + $this->assertContains('created:' . $user->id, EventsTestUser::$eventLog); + } + + public function testUpdatingAndUpdatedEventsAreFired(): void + { + EventsTestUser::updating(function (EventsTestUser $user) { + EventsTestUser::$eventLog[] = 'updating:' . $user->name; + }); + + EventsTestUser::updated(function (EventsTestUser $user) { + EventsTestUser::$eventLog[] = 'updated:' . $user->name; + }); + + $user = EventsTestUser::create([ + 'name' => 'Original Name', + 'email' => 'original@example.com', + ]); + + EventsTestUser::$eventLog = []; + + $user->name = 'Updated Name'; + $user->save(); + + $this->assertContains('updating:Updated Name', EventsTestUser::$eventLog); + $this->assertContains('updated:Updated Name', EventsTestUser::$eventLog); + } + + public function testSavingAndSavedEventsAreFired(): void + { + EventsTestUser::saving(function (EventsTestUser $user) { + EventsTestUser::$eventLog[] = 'saving:' . $user->name; + }); + + EventsTestUser::saved(function (EventsTestUser $user) { + EventsTestUser::$eventLog[] = 'saved:' . $user->name; + }); + + $user = EventsTestUser::create([ + 'name' => 'Save Test', + 'email' => 'save@example.com', + ]); + + $this->assertContains('saving:Save Test', EventsTestUser::$eventLog); + $this->assertContains('saved:Save Test', EventsTestUser::$eventLog); + } + + public function testDeletingAndDeletedEventsAreFired(): void + { + EventsTestUser::deleting(function (EventsTestUser $user) { + EventsTestUser::$eventLog[] = 'deleting:' . $user->id; + }); + + EventsTestUser::deleted(function (EventsTestUser $user) { + EventsTestUser::$eventLog[] = 'deleted:' . $user->id; + }); + + $user = EventsTestUser::create([ + 'name' => 'Delete Test', + 'email' => 'delete@example.com', + ]); + + $userId = $user->id; + EventsTestUser::$eventLog = []; + + $user->delete(); + + $this->assertContains('deleting:' . $userId, EventsTestUser::$eventLog); + $this->assertContains('deleted:' . $userId, EventsTestUser::$eventLog); + } + + public function testCreatingEventCanPreventCreation(): void + { + EventsTestUser::creating(function (EventsTestUser $user) { + if ($user->name === 'Blocked') { + return false; + } + }); + + $user = new EventsTestUser([ + 'name' => 'Blocked', + 'email' => 'blocked@example.com', + ]); + + $result = $user->save(); + + $this->assertFalse($result); + $this->assertFalse($user->exists); + $this->assertNull(EventsTestUser::where('email', 'blocked@example.com')->first()); + } + + public function testObserverMethodsAreCalled(): void + { + EventsTestUser::observe(EventsTestUserObserver::class); + + $user = EventsTestUser::create([ + 'name' => 'Observer Test', + 'email' => 'observer@example.com', + ]); + + $this->assertContains('observer:creating:Observer Test', EventsTestUser::$eventLog); + $this->assertContains('observer:created:' . $user->id, EventsTestUser::$eventLog); + } +} + +class EventsTestUser extends Model +{ + protected ?string $table = 'tmp_users'; + + protected array $fillable = ['name', 'email']; + + public static array $eventLog = []; +} + +class EventsTestUserObserver +{ + public function creating(EventsTestUser $user): void + { + EventsTestUser::$eventLog[] = 'observer:creating:' . $user->name; + } + + public function created(EventsTestUser $user): void + { + EventsTestUser::$eventLog[] = 'observer:created:' . $user->id; + } +} diff --git a/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php b/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php new file mode 100644 index 000000000..ec88dc3b0 --- /dev/null +++ b/tests/Integration/Database/Eloquent/ModelCoroutineSafetyTest.php @@ -0,0 +1,426 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + + protected function setUp(): void + { + parent::setUp(); + + CoroutineTestUser::$eventLog = []; + } + + public function testWithoutEventsDisablesEventsWithinCallback(): void + { + CoroutineTestUser::creating(function (CoroutineTestUser $user) { + CoroutineTestUser::$eventLog[] = 'creating:' . $user->name; + }); + + CoroutineTestUser::create(['name' => 'Normal', 'email' => 'normal@example.com']); + $this->assertContains('creating:Normal', CoroutineTestUser::$eventLog); + + CoroutineTestUser::$eventLog = []; + + Model::withoutEvents(function () { + CoroutineTestUser::create(['name' => 'Silent', 'email' => 'silent@example.com']); + }); + + $this->assertNotContains('creating:Silent', CoroutineTestUser::$eventLog); + $this->assertEmpty(CoroutineTestUser::$eventLog); + + CoroutineTestUser::create(['name' => 'AfterSilent', 'email' => 'after@example.com']); + $this->assertContains('creating:AfterSilent', CoroutineTestUser::$eventLog); + } + + public function testWithoutEventsRestoresStateAfterException(): void + { + $this->assertFalse(Model::eventsDisabled()); + + try { + Model::withoutEvents(function () { + $this->assertTrue(Model::eventsDisabled()); + throw new RuntimeException('Test exception'); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertFalse(Model::eventsDisabled()); + } + + public function testWithoutEventsSupportsNesting(): void + { + $this->assertFalse(Model::eventsDisabled()); + + Model::withoutEvents(function () { + $this->assertTrue(Model::eventsDisabled()); + + Model::withoutEvents(function () { + $this->assertTrue(Model::eventsDisabled()); + }); + + $this->assertTrue(Model::eventsDisabled()); + }); + + $this->assertFalse(Model::eventsDisabled()); + } + + public function testWithoutEventsIsCoroutineIsolated(): void + { + $channel = new Channel(2); + $waiter = new WaitGroup(); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + Model::withoutEvents(function () use ($channel) { + $channel->push(['coroutine' => 1, 'disabled' => Model::eventsDisabled()]); + usleep(50000); + }); + $waiter->done(); + }); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + usleep(10000); + $channel->push(['coroutine' => 2, 'disabled' => Model::eventsDisabled()]); + $waiter->done(); + }); + + $waiter->wait(); + $channel->close(); + + $results = []; + while (($result = $channel->pop()) !== false) { + $results[$result['coroutine']] = $result['disabled']; + } + + $this->assertTrue($results[1], 'Coroutine 1 should have events disabled'); + $this->assertFalse($results[2], 'Coroutine 2 should have events enabled (isolated context)'); + } + + public function testWithoutBroadcastingDisablesBroadcastingWithinCallback(): void + { + $this->assertTrue(Model::isBroadcasting()); + + Model::withoutBroadcasting(function () { + $this->assertFalse(Model::isBroadcasting()); + }); + + $this->assertTrue(Model::isBroadcasting()); + } + + public function testWithoutBroadcastingRestoresStateAfterException(): void + { + $this->assertTrue(Model::isBroadcasting()); + + try { + Model::withoutBroadcasting(function () { + $this->assertFalse(Model::isBroadcasting()); + throw new RuntimeException('Test exception'); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertTrue(Model::isBroadcasting()); + } + + public function testWithoutBroadcastingSupportsNesting(): void + { + $this->assertTrue(Model::isBroadcasting()); + + Model::withoutBroadcasting(function () { + $this->assertFalse(Model::isBroadcasting()); + + Model::withoutBroadcasting(function () { + $this->assertFalse(Model::isBroadcasting()); + }); + + $this->assertFalse(Model::isBroadcasting()); + }); + + $this->assertTrue(Model::isBroadcasting()); + } + + public function testWithoutBroadcastingIsCoroutineIsolated(): void + { + $channel = new Channel(2); + $waiter = new WaitGroup(); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + Model::withoutBroadcasting(function () use ($channel) { + $channel->push(['coroutine' => 1, 'broadcasting' => Model::isBroadcasting()]); + usleep(50000); + }); + $waiter->done(); + }); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + usleep(10000); + $channel->push(['coroutine' => 2, 'broadcasting' => Model::isBroadcasting()]); + $waiter->done(); + }); + + $waiter->wait(); + $channel->close(); + + $results = []; + while (($result = $channel->pop()) !== false) { + $results[$result['coroutine']] = $result['broadcasting']; + } + + $this->assertFalse($results[1], 'Coroutine 1 should have broadcasting disabled'); + $this->assertTrue($results[2], 'Coroutine 2 should have broadcasting enabled (isolated context)'); + } + + public function testWithoutTouchingDisablesTouchingWithinCallback(): void + { + $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); + + Model::withoutTouching(function () { + $this->assertTrue(Model::isIgnoringTouch(CoroutineTestUser::class)); + }); + + $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); + } + + public function testWithoutTouchingOnSpecificModels(): void + { + $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); + + Model::withoutTouchingOn([CoroutineTestUser::class], function () { + $this->assertTrue(Model::isIgnoringTouch(CoroutineTestUser::class)); + }); + + $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); + } + + public function testWithoutTouchingRestoresStateAfterException(): void + { + $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); + + try { + Model::withoutTouching(function () { + $this->assertTrue(Model::isIgnoringTouch(CoroutineTestUser::class)); + throw new RuntimeException('Test exception'); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); + } + + public function testWithoutTouchingSupportsNesting(): void + { + $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); + + Model::withoutTouching(function () { + $this->assertTrue(Model::isIgnoringTouch(CoroutineTestUser::class)); + + Model::withoutTouching(function () { + $this->assertTrue(Model::isIgnoringTouch(CoroutineTestUser::class)); + }); + + $this->assertTrue(Model::isIgnoringTouch(CoroutineTestUser::class)); + }); + + $this->assertFalse(Model::isIgnoringTouch(CoroutineTestUser::class)); + } + + public function testWithoutTouchingIsCoroutineIsolated(): void + { + $channel = new Channel(2); + $waiter = new WaitGroup(); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + Model::withoutTouching(function () use ($channel) { + $channel->push([ + 'coroutine' => 1, + 'ignoring' => Model::isIgnoringTouch(CoroutineTestUser::class), + ]); + usleep(50000); + }); + $waiter->done(); + }); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + usleep(10000); + $channel->push([ + 'coroutine' => 2, + 'ignoring' => Model::isIgnoringTouch(CoroutineTestUser::class), + ]); + $waiter->done(); + }); + + $waiter->wait(); + $channel->close(); + + $results = []; + while (($result = $channel->pop()) !== false) { + $results[$result['coroutine']] = $result['ignoring']; + } + + $this->assertTrue($results[1], 'Coroutine 1 should be ignoring touch'); + $this->assertFalse($results[2], 'Coroutine 2 should NOT be ignoring touch (isolated context)'); + } + + public function testWithoutRecursionIsCoroutineIsolated(): void + { + $model = new RecursionTestModel(); + $counter = $this->newRecursionCounter(); + $results = []; + $channel = new Channel(2); + $waiter = new WaitGroup(); + + $callback = function () use ($counter): int { + usleep(50000); + return ++$counter->value; + }; + + $waiter->add(1); + go(function () use ($model, $callback, $channel, $waiter): void { + $channel->push([ + 'coroutine' => 1, + 'result' => $model->runRecursionGuard($callback, -1), + ]); + $waiter->done(); + }); + + $waiter->add(1); + go(function () use ($model, $callback, $channel, $waiter): void { + usleep(10000); + $channel->push([ + 'coroutine' => 2, + 'result' => $model->runRecursionGuard($callback, -1), + ]); + $waiter->done(); + }); + + $waiter->wait(); + $channel->close(); + + while (($result = $channel->pop()) !== false) { + $results[$result['coroutine']] = $result['result']; + } + + sort($results); + + $this->assertSame([1, 2], $results); + $this->assertSame(2, $counter->value); + } + + public function testAllStateMethodsAreCoroutineIsolated(): void + { + $channel = new Channel(2); + $waiter = new WaitGroup(); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + Model::withoutEvents(function () use ($channel) { + Model::withoutBroadcasting(function () use ($channel) { + Model::withoutTouching(function () use ($channel) { + $channel->push([ + 'coroutine' => 1, + 'eventsDisabled' => Model::eventsDisabled(), + 'broadcasting' => Model::isBroadcasting(), + 'ignoringTouch' => Model::isIgnoringTouch(CoroutineTestUser::class), + ]); + usleep(50000); + }); + }); + }); + $waiter->done(); + }); + + $waiter->add(1); + go(function () use ($channel, $waiter) { + usleep(10000); + $channel->push([ + 'coroutine' => 2, + 'eventsDisabled' => Model::eventsDisabled(), + 'broadcasting' => Model::isBroadcasting(), + 'ignoringTouch' => Model::isIgnoringTouch(CoroutineTestUser::class), + ]); + $waiter->done(); + }); + + $waiter->wait(); + $channel->close(); + + $results = []; + while (($result = $channel->pop()) !== false) { + $results[$result['coroutine']] = $result; + } + + $this->assertTrue($results[1]['eventsDisabled'], 'Coroutine 1: events should be disabled'); + $this->assertFalse($results[1]['broadcasting'], 'Coroutine 1: broadcasting should be disabled'); + $this->assertTrue($results[1]['ignoringTouch'], 'Coroutine 1: should be ignoring touch'); + + $this->assertFalse($results[2]['eventsDisabled'], 'Coroutine 2: events should be enabled'); + $this->assertTrue($results[2]['broadcasting'], 'Coroutine 2: broadcasting should be enabled'); + $this->assertFalse($results[2]['ignoringTouch'], 'Coroutine 2: should NOT be ignoring touch'); + } + + private function newRecursionCounter(): object + { + return new class { + public int $value = 0; + }; + } +} + +class CoroutineTestUser extends Model +{ + protected ?string $table = 'tmp_users'; + + protected array $fillable = ['name', 'email']; + + public static array $eventLog = []; +} + +class RecursionTestModel extends Model +{ + public function runRecursionGuard(callable $callback, mixed $default = null): mixed + { + return $this->withoutRecursion($callback, $default); + } +} diff --git a/tests/Integration/Database/Eloquent/RelationsTest.php b/tests/Integration/Database/Eloquent/RelationsTest.php new file mode 100644 index 000000000..1f42cbb8e --- /dev/null +++ b/tests/Integration/Database/Eloquent/RelationsTest.php @@ -0,0 +1,426 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamps(); + }); + + Schema::create('rel_profiles', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained('rel_users')->onDelete('cascade'); + $table->string('bio')->nullable(); + $table->string('avatar')->nullable(); + $table->timestamps(); + }); + + Schema::create('rel_posts', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained('rel_users')->onDelete('cascade'); + $table->string('title'); + $table->text('body'); + $table->timestamps(); + }); + + Schema::create('rel_tags', function (Blueprint $table) { + $table->id(); + $table->string('name')->unique(); + $table->timestamps(); + }); + + Schema::create('rel_post_tag', function (Blueprint $table) { + $table->id(); + $table->foreignId('post_id')->constrained('rel_posts')->onDelete('cascade'); + $table->foreignId('tag_id')->constrained('rel_tags')->onDelete('cascade'); + $table->timestamps(); + }); + + Schema::create('rel_comments', function (Blueprint $table) { + $table->id(); + $table->morphs('commentable'); + $table->foreignId('user_id')->constrained('rel_users')->onDelete('cascade'); + $table->text('body'); + $table->timestamps(); + }); + } + + public function testHasOneRelation(): void + { + $user = RelUser::create(['name' => 'John', 'email' => 'john@example.com']); + $profile = $user->profile()->create(['bio' => 'Hello world', 'avatar' => 'avatar.jpg']); + + $this->assertInstanceOf(RelProfile::class, $profile); + $this->assertSame($user->id, $profile->user_id); + + $retrieved = RelUser::find($user->id); + $this->assertInstanceOf(RelProfile::class, $retrieved->profile); + $this->assertSame('Hello world', $retrieved->profile->bio); + } + + public function testBelongsToRelation(): void + { + $user = RelUser::create(['name' => 'Jane', 'email' => 'jane@example.com']); + $profile = $user->profile()->create(['bio' => 'Jane bio']); + + $retrieved = RelProfile::find($profile->id); + $this->assertInstanceOf(RelUser::class, $retrieved->user); + $this->assertSame('Jane', $retrieved->user->name); + } + + public function testHasManyRelation(): void + { + $user = RelUser::create(['name' => 'Bob', 'email' => 'bob@example.com']); + + $user->posts()->create(['title' => 'Post 1', 'body' => 'Body 1']); + $user->posts()->create(['title' => 'Post 2', 'body' => 'Body 2']); + $user->posts()->create(['title' => 'Post 3', 'body' => 'Body 3']); + + $retrieved = RelUser::find($user->id); + $this->assertCount(3, $retrieved->posts); + $this->assertInstanceOf(Collection::class, $retrieved->posts); + $this->assertInstanceOf(RelPost::class, $retrieved->posts->first()); + } + + public function testBelongsToManyRelation(): void + { + $user = RelUser::create(['name' => 'Alice', 'email' => 'alice@example.com']); + $post = $user->posts()->create(['title' => 'Tagged Post', 'body' => 'Body']); + + $tag1 = RelTag::create(['name' => 'PHP']); + $tag2 = RelTag::create(['name' => 'Laravel']); + $tag3 = RelTag::create(['name' => 'Hypervel']); + + $post->tags()->attach([$tag1->id, $tag2->id, $tag3->id]); + + $retrieved = RelPost::find($post->id); + $this->assertCount(3, $retrieved->tags); + $this->assertContains('PHP', $retrieved->tags->pluck('name')->toArray()); + } + + public function testBelongsToManyWithPivot(): void + { + $user = RelUser::create(['name' => 'Charlie', 'email' => 'charlie@example.com']); + $post = $user->posts()->create(['title' => 'Pivot Post', 'body' => 'Body']); + + $tag = RelTag::create(['name' => 'Testing']); + $post->tags()->attach($tag->id); + + $retrieved = RelPost::find($post->id); + $this->assertNotNull($retrieved->tags->first()->pivot); + $this->assertSame($post->id, $retrieved->tags->first()->pivot->post_id); + $this->assertSame($tag->id, $retrieved->tags->first()->pivot->tag_id); + } + + public function testSyncRelation(): void + { + $user = RelUser::create(['name' => 'Dave', 'email' => 'dave@example.com']); + $post = $user->posts()->create(['title' => 'Sync Post', 'body' => 'Body']); + + $tag1 = RelTag::create(['name' => 'Tag1']); + $tag2 = RelTag::create(['name' => 'Tag2']); + $tag3 = RelTag::create(['name' => 'Tag3']); + + $post->tags()->attach([$tag1->id, $tag2->id]); + $this->assertCount(2, $post->fresh()->tags); + + $post->tags()->sync([$tag2->id, $tag3->id]); + + $retrieved = $post->fresh(); + $this->assertCount(2, $retrieved->tags); + $this->assertContains('Tag2', $retrieved->tags->pluck('name')->toArray()); + $this->assertContains('Tag3', $retrieved->tags->pluck('name')->toArray()); + $this->assertNotContains('Tag1', $retrieved->tags->pluck('name')->toArray()); + } + + public function testDetachRelation(): void + { + $user = RelUser::create(['name' => 'Eve', 'email' => 'eve@example.com']); + $post = $user->posts()->create(['title' => 'Detach Post', 'body' => 'Body']); + + $tag1 = RelTag::create(['name' => 'DetachTag1']); + $tag2 = RelTag::create(['name' => 'DetachTag2']); + + $post->tags()->attach([$tag1->id, $tag2->id]); + $this->assertCount(2, $post->fresh()->tags); + + $post->tags()->detach($tag1->id); + $this->assertCount(1, $post->fresh()->tags); + + $post->tags()->detach(); + $this->assertCount(0, $post->fresh()->tags); + } + + public function testMorphManyRelation(): void + { + $user = RelUser::create(['name' => 'Frank', 'email' => 'frank@example.com']); + $post = $user->posts()->create(['title' => 'Morphed Post', 'body' => 'Body']); + + $post->comments()->create(['user_id' => $user->id, 'body' => 'Comment 1']); + $post->comments()->create(['user_id' => $user->id, 'body' => 'Comment 2']); + + $retrieved = RelPost::find($post->id); + $this->assertCount(2, $retrieved->comments); + $this->assertInstanceOf(RelComment::class, $retrieved->comments->first()); + } + + public function testMorphToRelation(): void + { + $user = RelUser::create(['name' => 'Grace', 'email' => 'grace@example.com']); + $post = $user->posts()->create(['title' => 'MorphTo Post', 'body' => 'Body']); + $comment = $post->comments()->create(['user_id' => $user->id, 'body' => 'A comment']); + + $retrieved = RelComment::find($comment->id); + $this->assertInstanceOf(RelPost::class, $retrieved->commentable); + $this->assertSame($post->id, $retrieved->commentable->id); + } + + public function testEagerLoadingWith(): void + { + $user = RelUser::create(['name' => 'Henry', 'email' => 'henry@example.com']); + $user->profile()->create(['bio' => 'Henry bio']); + $user->posts()->create(['title' => 'Post 1', 'body' => 'Body 1']); + $user->posts()->create(['title' => 'Post 2', 'body' => 'Body 2']); + + $retrieved = RelUser::with(['profile', 'posts'])->find($user->id); + + $this->assertTrue($retrieved->relationLoaded('profile')); + $this->assertTrue($retrieved->relationLoaded('posts')); + $this->assertSame('Henry bio', $retrieved->profile->bio); + $this->assertCount(2, $retrieved->posts); + } + + public function testEagerLoadingWithCount(): void + { + $user = RelUser::create(['name' => 'Ivy', 'email' => 'ivy@example.com']); + $user->posts()->create(['title' => 'Post 1', 'body' => 'Body 1']); + $user->posts()->create(['title' => 'Post 2', 'body' => 'Body 2']); + $user->posts()->create(['title' => 'Post 3', 'body' => 'Body 3']); + + $retrieved = RelUser::withCount('posts')->find($user->id); + + $this->assertSame(3, $retrieved->posts_count); + } + + public function testNestedEagerLoading(): void + { + $user = RelUser::create(['name' => 'Jack', 'email' => 'jack@example.com']); + $post = $user->posts()->create(['title' => 'Nested Post', 'body' => 'Body']); + + $tag = RelTag::create(['name' => 'Nested Tag']); + $post->tags()->attach($tag->id); + + $retrieved = RelUser::with('posts.tags')->find($user->id); + + $this->assertTrue($retrieved->relationLoaded('posts')); + $this->assertTrue($retrieved->posts->first()->relationLoaded('tags')); + $this->assertSame('Nested Tag', $retrieved->posts->first()->tags->first()->name); + } + + public function testHasQueryConstraint(): void + { + $user1 = RelUser::create(['name' => 'Kate', 'email' => 'kate@example.com']); + $user2 = RelUser::create(['name' => 'Liam', 'email' => 'liam@example.com']); + + $user1->posts()->create(['title' => 'Kate Post', 'body' => 'Body']); + + $usersWithPosts = RelUser::has('posts')->get(); + + $this->assertCount(1, $usersWithPosts); + $this->assertSame('Kate', $usersWithPosts->first()->name); + } + + public function testDoesntHaveQueryConstraint(): void + { + $user1 = RelUser::create(['name' => 'Mike', 'email' => 'mike@example.com']); + $user2 = RelUser::create(['name' => 'Nancy', 'email' => 'nancy@example.com']); + + $user1->posts()->create(['title' => 'Mike Post', 'body' => 'Body']); + + $usersWithoutPosts = RelUser::doesntHave('posts')->get(); + + $this->assertCount(1, $usersWithoutPosts); + $this->assertSame('Nancy', $usersWithoutPosts->first()->name); + } + + public function testWhereHasQueryConstraint(): void + { + $user1 = RelUser::create(['name' => 'Oscar', 'email' => 'oscar@example.com']); + $user2 = RelUser::create(['name' => 'Paula', 'email' => 'paula@example.com']); + + $user1->posts()->create(['title' => 'PHP Tutorial', 'body' => 'Body']); + $user2->posts()->create(['title' => 'JavaScript Guide', 'body' => 'Body']); + + $users = RelUser::whereHas('posts', function ($query) { + $query->where('title', 'like', '%PHP%'); + })->get(); + + $this->assertCount(1, $users); + $this->assertSame('Oscar', $users->first()->name); + } + + public function testSaveRelatedModel(): void + { + $user = RelUser::create(['name' => 'Quinn', 'email' => 'quinn@example.com']); + + $post = new RelPost(['title' => 'Saved Post', 'body' => 'Body']); + $user->posts()->save($post); + + $this->assertTrue($post->exists); + $this->assertSame($user->id, $post->user_id); + } + + public function testSaveManyRelatedModels(): void + { + $user = RelUser::create(['name' => 'Rachel', 'email' => 'rachel@example.com']); + + $posts = [ + new RelPost(['title' => 'Post A', 'body' => 'Body A']), + new RelPost(['title' => 'Post B', 'body' => 'Body B']), + ]; + + $user->posts()->saveMany($posts); + + $this->assertCount(2, $user->fresh()->posts); + } + + public function testCreateManyRelatedModels(): void + { + $user = RelUser::create(['name' => 'Steve', 'email' => 'steve@example.com']); + + $user->posts()->createMany([ + ['title' => 'Created 1', 'body' => 'Body 1'], + ['title' => 'Created 2', 'body' => 'Body 2'], + ]); + + $this->assertCount(2, $user->fresh()->posts); + } + + public function testAssociateBelongsTo(): void + { + $user = RelUser::create(['name' => 'Tom', 'email' => 'tom@example.com']); + $post = RelPost::create(['user_id' => $user->id, 'title' => 'Initial', 'body' => 'Body']); + + $newUser = RelUser::create(['name' => 'Uma', 'email' => 'uma@example.com']); + + $post->user()->associate($newUser); + $post->save(); + + $this->assertSame($newUser->id, $post->fresh()->user_id); + } + + public function testDissociateBelongsTo(): void + { + $user = RelUser::create(['name' => 'Victor', 'email' => 'victor@example.com']); + $profile = $user->profile()->create(['bio' => 'Victor bio']); + + $profile->user()->dissociate(); + $profile->save(); + + $this->assertNull($profile->fresh()->user_id); + } +} + +class RelUser extends Model +{ + protected ?string $table = 'rel_users'; + + protected array $fillable = ['name', 'email']; + + public function profile(): HasOne + { + return $this->hasOne(RelProfile::class, 'user_id'); + } + + public function posts(): HasMany + { + return $this->hasMany(RelPost::class, 'user_id'); + } +} + +class RelProfile extends Model +{ + protected ?string $table = 'rel_profiles'; + + protected array $fillable = ['user_id', 'bio', 'avatar']; + + public function user(): BelongsTo + { + return $this->belongsTo(RelUser::class, 'user_id'); + } +} + +class RelPost extends Model +{ + protected ?string $table = 'rel_posts'; + + protected array $fillable = ['user_id', 'title', 'body']; + + public function user(): BelongsTo + { + return $this->belongsTo(RelUser::class, 'user_id'); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(RelTag::class, 'rel_post_tag', 'post_id', 'tag_id')->withTimestamps(); + } + + public function comments(): MorphMany + { + return $this->morphMany(RelComment::class, 'commentable'); + } +} + +class RelTag extends Model +{ + protected ?string $table = 'rel_tags'; + + protected array $fillable = ['name']; + + public function posts(): BelongsToMany + { + return $this->belongsToMany(RelPost::class, 'rel_post_tag', 'tag_id', 'post_id'); + } +} + +class RelComment extends Model +{ + protected ?string $table = 'rel_comments'; + + protected array $fillable = ['user_id', 'body']; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(RelUser::class, 'user_id'); + } +} diff --git a/tests/Integration/Database/Eloquent/ScopesTest.php b/tests/Integration/Database/Eloquent/ScopesTest.php new file mode 100644 index 000000000..e6f6cf662 --- /dev/null +++ b/tests/Integration/Database/Eloquent/ScopesTest.php @@ -0,0 +1,330 @@ +id(); + $table->string('title'); + $table->string('status')->default('draft'); + $table->string('category')->nullable(); + $table->integer('views')->default(0); + $table->boolean('is_featured')->default(false); + $table->foreignId('author_id')->nullable(); + $table->timestamps(); + }); + + Schema::create('scope_authors', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + } + + protected function seedArticles(): void + { + ScopeArticle::create(['title' => 'Published Article 1', 'status' => 'published', 'category' => 'tech', 'views' => 100, 'is_featured' => true]); + ScopeArticle::create(['title' => 'Published Article 2', 'status' => 'published', 'category' => 'tech', 'views' => 50]); + ScopeArticle::create(['title' => 'Draft Article', 'status' => 'draft', 'category' => 'news', 'views' => 0]); + ScopeArticle::create(['title' => 'Archived Article', 'status' => 'archived', 'category' => 'tech', 'views' => 200]); + ScopeArticle::create(['title' => 'Popular Article', 'status' => 'published', 'category' => 'news', 'views' => 500, 'is_featured' => true]); + } + + public function testLocalScope(): void + { + $this->seedArticles(); + + $published = ScopeArticle::published()->get(); + + $this->assertCount(3, $published); + foreach ($published as $article) { + $this->assertSame('published', $article->status); + } + } + + public function testLocalScopeWithParameter(): void + { + $this->seedArticles(); + + $techArticles = ScopeArticle::inCategory('tech')->get(); + + $this->assertCount(3, $techArticles); + foreach ($techArticles as $article) { + $this->assertSame('tech', $article->category); + } + } + + public function testMultipleScopesCombined(): void + { + $this->seedArticles(); + + $publishedTech = ScopeArticle::published()->inCategory('tech')->get(); + + $this->assertCount(2, $publishedTech); + } + + public function testScopeWithMinViews(): void + { + $this->seedArticles(); + + $popular = ScopeArticle::minViews(100)->get(); + + $this->assertCount(3, $popular); + foreach ($popular as $article) { + $this->assertGreaterThanOrEqual(100, $article->views); + } + } + + public function testFeaturedScope(): void + { + $this->seedArticles(); + + $featured = ScopeArticle::featured()->get(); + + $this->assertCount(2, $featured); + foreach ($featured as $article) { + $this->assertTrue($article->is_featured); + } + } + + public function testChainingMultipleScopes(): void + { + $this->seedArticles(); + + $result = ScopeArticle::published() + ->featured() + ->minViews(50) + ->get(); + + $this->assertCount(2, $result); + } + + public function testScopeWithOrderBy(): void + { + $this->seedArticles(); + + $articles = ScopeArticle::popular()->get(); + + $this->assertSame('Popular Article', $articles->first()->title); + $this->assertSame('Draft Article', $articles->last()->title); + } + + public function testGlobalScope(): void + { + ScopeArticle::query()->delete(); + + GlobalScopeArticle::create(['title' => 'Global Published', 'status' => 'published']); + GlobalScopeArticle::create(['title' => 'Global Draft', 'status' => 'draft']); + + $all = GlobalScopeArticle::all(); + + $this->assertCount(1, $all); + $this->assertSame('Global Published', $all->first()->title); + } + + public function testWithoutGlobalScope(): void + { + ScopeArticle::query()->delete(); + + GlobalScopeArticle::create(['title' => 'Without Scope Published', 'status' => 'published']); + GlobalScopeArticle::create(['title' => 'Without Scope Draft', 'status' => 'draft']); + + $all = GlobalScopeArticle::withoutGlobalScope(PublishedScope::class)->get(); + + $this->assertCount(2, $all); + } + + public function testWithoutGlobalScopes(): void + { + ScopeArticle::query()->delete(); + + GlobalScopeArticle::create(['title' => 'Test Published', 'status' => 'published']); + GlobalScopeArticle::create(['title' => 'Test Draft', 'status' => 'draft']); + + $all = GlobalScopeArticle::withoutGlobalScopes()->get(); + + $this->assertCount(2, $all); + } + + public function testDynamicScope(): void + { + $this->seedArticles(); + + $articles = ScopeArticle::status('archived')->get(); + + $this->assertCount(1, $articles); + $this->assertSame('Archived Article', $articles->first()->title); + } + + public function testScopeOnRelation(): void + { + $this->seedArticles(); + + $author = ScopeAuthor::create(['name' => 'John']); + + ScopeArticle::where('title', 'Published Article 1')->update(['author_id' => $author->id]); + ScopeArticle::where('title', 'Draft Article')->update(['author_id' => $author->id]); + ScopeArticle::where('title', 'Archived Article')->update(['author_id' => $author->id]); + + $publishedByAuthor = $author->articles()->published()->get(); + + $this->assertCount(1, $publishedByAuthor); + $this->assertSame('Published Article 1', $publishedByAuthor->first()->title); + } + + public function testScopeWithCount(): void + { + $this->seedArticles(); + + $count = ScopeArticle::published()->count(); + + $this->assertSame(3, $count); + } + + public function testScopeWithFirst(): void + { + $this->seedArticles(); + + $article = ScopeArticle::published()->inCategory('news')->first(); + + $this->assertNotNull($article); + $this->assertSame('Popular Article', $article->title); + } + + public function testScopeWithExists(): void + { + $this->seedArticles(); + + $this->assertTrue(ScopeArticle::published()->exists()); + $this->assertFalse(ScopeArticle::status('nonexistent')->exists()); + } + + public function testScopeReturnsBuilder(): void + { + $builder = ScopeArticle::published(); + + $this->assertInstanceOf(Builder::class, $builder); + } + + public function testScopeWithPluck(): void + { + $this->seedArticles(); + + $titles = ScopeArticle::published()->pluck('title'); + + $this->assertCount(3, $titles); + $this->assertContains('Published Article 1', $titles->toArray()); + } + + public function testScopeWithAggregate(): void + { + $this->seedArticles(); + + $totalViews = ScopeArticle::published()->sum('views'); + + $this->assertEquals(650, $totalViews); + } + + public function testOrScope(): void + { + $this->seedArticles(); + + $articles = ScopeArticle::where(function ($query) { + $query->featured()->orWhere('views', '>', 100); + })->get(); + + $this->assertCount(3, $articles); + } +} + +class ScopeArticle extends Model +{ + protected ?string $table = 'scope_articles'; + + protected array $fillable = ['title', 'status', 'category', 'views', 'is_featured', 'author_id']; + + protected array $casts = ['is_featured' => 'boolean']; + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', 'published'); + } + + public function scopeInCategory(Builder $query, string $category): Builder + { + return $query->where('category', $category); + } + + public function scopeMinViews(Builder $query, int $views): Builder + { + return $query->where('views', '>=', $views); + } + + public function scopeFeatured(Builder $query): Builder + { + return $query->where('is_featured', true); + } + + public function scopePopular(Builder $query): Builder + { + return $query->orderBy('views', 'desc'); + } + + public function scopeStatus(Builder $query, string $status): Builder + { + return $query->where('status', $status); + } + + public function author() + { + return $this->belongsTo(ScopeAuthor::class, 'author_id'); + } +} + +class ScopeAuthor extends Model +{ + protected ?string $table = 'scope_authors'; + + protected array $fillable = ['name']; + + public function articles() + { + return $this->hasMany(ScopeArticle::class, 'author_id'); + } +} + +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('status', 'published'); + } +} + +class GlobalScopeArticle extends Model +{ + protected ?string $table = 'scope_articles'; + + protected array $fillable = ['title', 'status', 'category', 'views', 'is_featured']; + + protected static function booted(): void + { + static::addGlobalScope(new PublishedScope()); + } +} diff --git a/tests/Integration/Database/Eloquent/SoftDeletesTest.php b/tests/Integration/Database/Eloquent/SoftDeletesTest.php new file mode 100644 index 000000000..ec3531802 --- /dev/null +++ b/tests/Integration/Database/Eloquent/SoftDeletesTest.php @@ -0,0 +1,276 @@ +id(); + $table->string('title'); + $table->text('body'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function testSoftDeleteSetsDeletedAt(): void + { + $post = SoftPost::create(['title' => 'Test Post', 'body' => 'Test Body']); + + $this->assertNull($post->deleted_at); + + $post->delete(); + + $this->assertNotNull($post->deleted_at); + $this->assertInstanceOf(CarbonInterface::class, $post->deleted_at); + } + + public function testSoftDeletedModelsAreExcludedByDefault(): void + { + $post1 = SoftPost::create(['title' => 'Post 1', 'body' => 'Body 1']); + $post2 = SoftPost::create(['title' => 'Post 2', 'body' => 'Body 2']); + $post3 = SoftPost::create(['title' => 'Post 3', 'body' => 'Body 3']); + + $post2->delete(); + + $posts = SoftPost::all(); + + $this->assertCount(2, $posts); + $this->assertNull(SoftPost::find($post2->id)); + } + + public function testWithTrashedIncludesSoftDeleted(): void + { + $post1 = SoftPost::create(['title' => 'Post 1', 'body' => 'Body 1']); + $post2 = SoftPost::create(['title' => 'Post 2', 'body' => 'Body 2']); + + $post2->delete(); + + $posts = SoftPost::withTrashed()->get(); + + $this->assertCount(2, $posts); + } + + public function testOnlyTrashedReturnsOnlySoftDeleted(): void + { + $post1 = SoftPost::create(['title' => 'Post 1', 'body' => 'Body 1']); + $post2 = SoftPost::create(['title' => 'Post 2', 'body' => 'Body 2']); + $post3 = SoftPost::create(['title' => 'Post 3', 'body' => 'Body 3']); + + $post1->delete(); + $post3->delete(); + + $trashedPosts = SoftPost::onlyTrashed()->get(); + + $this->assertCount(2, $trashedPosts); + $this->assertContains('Post 1', $trashedPosts->pluck('title')->toArray()); + $this->assertContains('Post 3', $trashedPosts->pluck('title')->toArray()); + } + + public function testTrashedMethodReturnsTrue(): void + { + $post = SoftPost::create(['title' => 'Test', 'body' => 'Body']); + + $this->assertFalse($post->trashed()); + + $post->delete(); + + $this->assertTrue($post->trashed()); + } + + public function testRestoreModel(): void + { + $post = SoftPost::create(['title' => 'Restore Test', 'body' => 'Body']); + $post->delete(); + + $this->assertTrue($post->trashed()); + $this->assertNull(SoftPost::find($post->id)); + + $post->restore(); + + $this->assertFalse($post->trashed()); + $this->assertNull($post->deleted_at); + $this->assertNotNull(SoftPost::find($post->id)); + } + + public function testForceDeletePermanentlyRemoves(): void + { + $post = SoftPost::create(['title' => 'Force Delete Test', 'body' => 'Body']); + $postId = $post->id; + + $post->forceDelete(); + + $this->assertNull(SoftPost::withTrashed()->find($postId)); + } + + public function testSoftDeletedEventsAreFired(): void + { + SoftPost::$eventLog = []; + + SoftPost::deleting(function (SoftPost $post) { + SoftPost::$eventLog[] = 'deleting:' . $post->id; + }); + + SoftPost::deleted(function (SoftPost $post) { + SoftPost::$eventLog[] = 'deleted:' . $post->id; + }); + + $post = SoftPost::create(['title' => 'Event Test', 'body' => 'Body']); + $postId = $post->id; + + $post->delete(); + + $this->assertContains('deleting:' . $postId, SoftPost::$eventLog); + $this->assertContains('deleted:' . $postId, SoftPost::$eventLog); + } + + public function testRestoringAndRestoredEventsAreFired(): void + { + SoftPost::$eventLog = []; + + SoftPost::restoring(function (SoftPost $post) { + SoftPost::$eventLog[] = 'restoring:' . $post->id; + }); + + SoftPost::restored(function (SoftPost $post) { + SoftPost::$eventLog[] = 'restored:' . $post->id; + }); + + $post = SoftPost::create(['title' => 'Restore Event Test', 'body' => 'Body']); + $postId = $post->id; + $post->delete(); + + SoftPost::$eventLog = []; + + $post->restore(); + + $this->assertContains('restoring:' . $postId, SoftPost::$eventLog); + $this->assertContains('restored:' . $postId, SoftPost::$eventLog); + } + + public function testForceDeletedEventsAreFired(): void + { + SoftPost::$eventLog = []; + + SoftPost::forceDeleting(function (SoftPost $post) { + SoftPost::$eventLog[] = 'forceDeleting:' . $post->id; + }); + + SoftPost::forceDeleted(function (SoftPost $post) { + SoftPost::$eventLog[] = 'forceDeleted:' . $post->id; + }); + + $post = SoftPost::create(['title' => 'Force Delete Event Test', 'body' => 'Body']); + $postId = $post->id; + + $post->forceDelete(); + + $this->assertContains('forceDeleting:' . $postId, SoftPost::$eventLog); + $this->assertContains('forceDeleted:' . $postId, SoftPost::$eventLog); + } + + public function testWithTrashedOnFind(): void + { + $post = SoftPost::create(['title' => 'Find Test', 'body' => 'Body']); + $postId = $post->id; + $post->delete(); + + $notFound = SoftPost::find($postId); + $this->assertNull($notFound); + + $found = SoftPost::withTrashed()->find($postId); + $this->assertNotNull($found); + $this->assertSame('Find Test', $found->title); + } + + public function testQueryBuilderWhereOnSoftDeletes(): void + { + $post1 = SoftPost::create(['title' => 'Active Post', 'body' => 'Body']); + $post2 = SoftPost::create(['title' => 'Deleted Post', 'body' => 'Body']); + $post2->delete(); + + $results = SoftPost::where('title', 'like', '%Post%')->get(); + $this->assertCount(1, $results); + + $resultsWithTrashed = SoftPost::withTrashed()->where('title', 'like', '%Post%')->get(); + $this->assertCount(2, $resultsWithTrashed); + } + + public function testCountWithSoftDeletes(): void + { + SoftPost::create(['title' => 'Post 1', 'body' => 'Body']); + SoftPost::create(['title' => 'Post 2', 'body' => 'Body']); + $post3 = SoftPost::create(['title' => 'Post 3', 'body' => 'Body']); + $post3->delete(); + + $this->assertSame(2, SoftPost::count()); + $this->assertSame(3, SoftPost::withTrashed()->count()); + $this->assertSame(1, SoftPost::onlyTrashed()->count()); + } + + public function testDeleteByQuery(): void + { + SoftPost::create(['title' => 'PHP Post', 'body' => 'Body']); + SoftPost::create(['title' => 'PHP Tutorial', 'body' => 'Body']); + SoftPost::create(['title' => 'Laravel Post', 'body' => 'Body']); + + SoftPost::where('title', 'like', 'PHP%')->delete(); + + $this->assertSame(1, SoftPost::count()); + $this->assertSame(2, SoftPost::onlyTrashed()->count()); + } + + public function testRestoreByQuery(): void + { + $post1 = SoftPost::create(['title' => 'Restore 1', 'body' => 'Body']); + $post2 = SoftPost::create(['title' => 'Restore 2', 'body' => 'Body']); + $post3 = SoftPost::create(['title' => 'Keep Deleted', 'body' => 'Body']); + + $post1->delete(); + $post2->delete(); + $post3->delete(); + + SoftPost::onlyTrashed()->where('title', 'like', 'Restore%')->restore(); + + $this->assertSame(2, SoftPost::count()); + $this->assertSame(1, SoftPost::onlyTrashed()->count()); + } + + public function testForceDeleteByQuery(): void + { + SoftPost::create(['title' => 'Keep 1', 'body' => 'Body']); + SoftPost::create(['title' => 'Force Delete 1', 'body' => 'Body']); + SoftPost::create(['title' => 'Force Delete 2', 'body' => 'Body']); + + SoftPost::where('title', 'like', 'Force Delete%')->forceDelete(); + + $this->assertSame(1, SoftPost::count()); + $this->assertSame(1, SoftPost::withTrashed()->count()); + } +} + +class SoftPost extends Model +{ + use SoftDeletes; + + protected ?string $table = 'soft_posts'; + + protected array $fillable = ['title', 'body']; + + public static array $eventLog = []; +} diff --git a/tests/Integration/Database/Postgres/PooledConnectionStateTest.php b/tests/Integration/Database/Postgres/PooledConnectionStateTest.php new file mode 100644 index 000000000..2f3fc7e21 --- /dev/null +++ b/tests/Integration/Database/Postgres/PooledConnectionStateTest.php @@ -0,0 +1,227 @@ +app->get(PoolFactory::class); + $pool = $factory->getPool($this->driver); + + return $pool->get(); + } + + public function testQueryLoggingStateDoesNotLeakBetweenCoroutines(): void + { + $coroutine2LoggingState = null; + $coroutine2QueryLog = null; + + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $connection1->enableQueryLog(); + $connection1->select('SELECT 1'); + + $this->assertTrue($connection1->logging()); + $this->assertNotEmpty($connection1->getQueryLog()); + + $pooled1->release(); + usleep(1000); + + go(function () use (&$coroutine2LoggingState, &$coroutine2QueryLog) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $coroutine2LoggingState = $connection2->logging(); + $coroutine2QueryLog = $connection2->getQueryLog(); + + $pooled2->release(); + }); + + $this->assertFalse( + $coroutine2LoggingState, + 'Query logging should be disabled for new coroutine (state leaked from previous)' + ); + $this->assertEmpty( + $coroutine2QueryLog, + 'Query log should be empty for new coroutine (state leaked from previous)' + ); + } + + public function testQueryDurationHandlersDoNotLeakBetweenCoroutines(): void + { + $coroutine2HandlerCount = null; + + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $connection1->whenQueryingForLongerThan(1000, function () { + // Handler that would fire after 1 second of queries + }); + + $reflection = new ReflectionProperty(Connection::class, 'queryDurationHandlers'); + $this->assertCount(1, $reflection->getValue($connection1)); + + $pooled1->release(); + usleep(1000); + + go(function () use (&$coroutine2HandlerCount) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $reflection = new ReflectionProperty(Connection::class, 'queryDurationHandlers'); + $coroutine2HandlerCount = count($reflection->getValue($connection2)); + + $pooled2->release(); + }); + + $this->assertEquals( + 0, + $coroutine2HandlerCount, + 'Query duration handlers array should be empty for new coroutine (state leaked from previous)' + ); + } + + public function testTotalQueryDurationDoesNotLeakBetweenCoroutines(): void + { + $coroutine2Duration = null; + + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + for ($i = 0; $i < 10; ++$i) { + $connection1->select('SELECT pg_sleep(0.001)'); + } + + $duration1 = $connection1->totalQueryDuration(); + $this->assertGreaterThan(0, $duration1); + + $pooled1->release(); + usleep(1000); + + go(function () use (&$coroutine2Duration) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $coroutine2Duration = $connection2->totalQueryDuration(); + + $pooled2->release(); + }); + + $this->assertEquals( + 0.0, + $coroutine2Duration, + 'Total query duration should be reset for new coroutine (state leaked from previous)' + ); + } + + public function testBeforeStartingTransactionCallbacksDoNotLeakBetweenCoroutines(): void + { + $callbackCalledInCoroutine2 = false; + + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $connection1->beforeStartingTransaction(function () use (&$callbackCalledInCoroutine2) { + $callbackCalledInCoroutine2 = true; + }); + + $pooled1->release(); + usleep(1000); + + go(function () use (&$callbackCalledInCoroutine2) { + $callbackCalledInCoroutine2 = false; + + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $connection2->beginTransaction(); + $connection2->rollBack(); + + $pooled2->release(); + }); + + $this->assertFalse( + $callbackCalledInCoroutine2, + 'beforeStartingTransaction callback from previous coroutine should not fire (state leaked)' + ); + } + + public function testReadOnWriteConnectionFlagDoesNotLeakBetweenCoroutines(): void + { + $coroutine2UsesWriteForReads = null; + + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $connection1->useWriteConnectionWhenReading(true); + + $pooled1->release(); + usleep(1000); + + go(function () use (&$coroutine2UsesWriteForReads) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $reflection = new ReflectionProperty(Connection::class, 'readOnWriteConnection'); + $coroutine2UsesWriteForReads = $reflection->getValue($connection2); + + $pooled2->release(); + }); + + $this->assertFalse( + $coroutine2UsesWriteForReads, + 'readOnWriteConnection flag should be false for new coroutine (state leaked from previous)' + ); + } + + public function testPretendingFlagDoesNotLeakBetweenCoroutines(): void + { + $coroutine2Pretending = null; + + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $reflection = new ReflectionProperty(Connection::class, 'pretending'); + $reflection->setValue($connection1, true); + + $pooled1->release(); + usleep(1000); + + go(function () use (&$coroutine2Pretending) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $coroutine2Pretending = $connection2->pretending(); + + $pooled2->release(); + }); + + $this->assertFalse( + $coroutine2Pretending, + 'Pretending flag should be false for new coroutine (state leaked from previous)' + ); + } +} diff --git a/tests/Integration/Database/Postgres/PostgresTestCase.php b/tests/Integration/Database/Postgres/PostgresTestCase.php new file mode 100644 index 000000000..09ef1d56b --- /dev/null +++ b/tests/Integration/Database/Postgres/PostgresTestCase.php @@ -0,0 +1,13 @@ +id(); + $table->string('name'); + $table->string('category')->nullable(); + $table->decimal('price', 10, 2); + $table->integer('stock')->default(0); + $table->boolean('active')->default(true); + $table->timestamps(); + }); + } + + protected function seedProducts(): void + { + DB::table('qb_products')->insert([ + ['name' => 'Widget A', 'category' => 'widgets', 'price' => 19.99, 'stock' => 100, 'active' => true, 'created_at' => now(), 'updated_at' => now()], + ['name' => 'Widget B', 'category' => 'widgets', 'price' => 29.99, 'stock' => 50, 'active' => true, 'created_at' => now(), 'updated_at' => now()], + ['name' => 'Gadget X', 'category' => 'gadgets', 'price' => 99.99, 'stock' => 25, 'active' => true, 'created_at' => now(), 'updated_at' => now()], + ['name' => 'Gadget Y', 'category' => 'gadgets', 'price' => 149.99, 'stock' => 10, 'active' => false, 'created_at' => now(), 'updated_at' => now()], + ['name' => 'Tool Z', 'category' => 'tools', 'price' => 49.99, 'stock' => 0, 'active' => true, 'created_at' => now(), 'updated_at' => now()], + ]); + } + + protected function table(): Builder + { + return DB::table('qb_products'); + } + + public function testSelectAll(): void + { + $this->seedProducts(); + + $products = $this->table()->get(); + + $this->assertCount(5, $products); + } + + public function testSelectSpecificColumns(): void + { + $this->seedProducts(); + + $product = $this->table()->select('name', 'price')->first(); + + $this->assertSame('Widget A', $product->name); + $this->assertEquals(19.99, $product->price); + $this->assertObjectNotHasProperty('category', $product); + } + + public function testWhereEquals(): void + { + $this->seedProducts(); + + $products = $this->table()->where('category', 'widgets')->get(); + + $this->assertCount(2, $products); + } + + public function testWhereWithOperator(): void + { + $this->seedProducts(); + + $products = $this->table()->where('price', '>', 50)->get(); + + $this->assertCount(2, $products); + } + + public function testWhereIn(): void + { + $this->seedProducts(); + + $products = $this->table()->whereIn('category', ['widgets', 'tools'])->get(); + + $this->assertCount(3, $products); + } + + public function testWhereNotIn(): void + { + $this->seedProducts(); + + $products = $this->table()->whereNotIn('category', ['widgets'])->get(); + + $this->assertCount(3, $products); + } + + public function testWhereNull(): void + { + $this->seedProducts(); + + $this->table()->where('name', 'Tool Z')->update(['category' => null]); + + $products = $this->table()->whereNull('category')->get(); + + $this->assertCount(1, $products); + $this->assertSame('Tool Z', $products->first()->name); + } + + public function testWhereNotNull(): void + { + $this->seedProducts(); + + $this->table()->where('name', 'Tool Z')->update(['category' => null]); + + $products = $this->table()->whereNotNull('category')->get(); + + $this->assertCount(4, $products); + } + + public function testWhereBetween(): void + { + $this->seedProducts(); + + $products = $this->table()->whereBetween('price', [20, 100])->get(); + + $this->assertCount(3, $products); + } + + public function testOrWhere(): void + { + $this->seedProducts(); + + $products = $this->table() + ->where('category', 'widgets') + ->orWhere('category', 'tools') + ->get(); + + $this->assertCount(3, $products); + } + + public function testWhereNested(): void + { + $this->seedProducts(); + + $products = $this->table() + ->where('active', true) + ->where(function ($query) { + $query->where('category', 'widgets') + ->orWhere('price', '>', 100); + }) + ->get(); + + $this->assertCount(2, $products); + } + + public function testOrderBy(): void + { + $this->seedProducts(); + + $products = $this->table()->orderBy('price', 'desc')->get(); + + $this->assertSame('Gadget Y', $products->first()->name); + $this->assertSame('Widget A', $products->last()->name); + } + + public function testOrderByMultiple(): void + { + $this->seedProducts(); + + $products = $this->table() + ->orderBy('category') + ->orderBy('price', 'desc') + ->get(); + + $first = $products->first(); + $this->assertSame('gadgets', $first->category); + $this->assertEquals(149.99, $first->price); + } + + public function testLimit(): void + { + $this->seedProducts(); + + $products = $this->table()->limit(2)->get(); + + $this->assertCount(2, $products); + } + + public function testOffset(): void + { + $this->seedProducts(); + + $products = $this->table()->orderBy('id')->offset(2)->limit(2)->get(); + + $this->assertCount(2, $products); + $this->assertSame('Gadget X', $products->first()->name); + } + + public function testFirst(): void + { + $this->seedProducts(); + + $product = $this->table()->where('category', 'gadgets')->first(); + + $this->assertSame('Gadget X', $product->name); + } + + public function testFind(): void + { + $this->seedProducts(); + + $first = $this->table()->first(); + $product = $this->table()->find($first->id); + + $this->assertSame($first->name, $product->name); + } + + public function testValue(): void + { + $this->seedProducts(); + + $name = $this->table()->where('category', 'tools')->value('name'); + + $this->assertSame('Tool Z', $name); + } + + public function testPluck(): void + { + $this->seedProducts(); + + $names = $this->table()->where('category', 'widgets')->pluck('name'); + + $this->assertCount(2, $names); + $this->assertContains('Widget A', $names->toArray()); + $this->assertContains('Widget B', $names->toArray()); + } + + public function testPluckWithKey(): void + { + $this->seedProducts(); + + $products = $this->table()->where('category', 'widgets')->pluck('name', 'id'); + + $this->assertCount(2, $products); + foreach ($products as $id => $name) { + $this->assertIsInt($id); + $this->assertIsString($name); + } + } + + public function testCount(): void + { + $this->seedProducts(); + + $count = $this->table()->where('active', true)->count(); + + $this->assertSame(4, $count); + } + + public function testMax(): void + { + $this->seedProducts(); + + $max = $this->table()->max('price'); + + $this->assertEquals(149.99, $max); + } + + public function testMin(): void + { + $this->seedProducts(); + + $min = $this->table()->min('price'); + + $this->assertEquals(19.99, $min); + } + + public function testSum(): void + { + $this->seedProducts(); + + $sum = $this->table()->where('category', 'widgets')->sum('stock'); + + $this->assertEquals(150, $sum); + } + + public function testAvg(): void + { + $this->seedProducts(); + + $avg = $this->table()->where('category', 'widgets')->avg('price'); + + $this->assertEquals(24.99, $avg); + } + + public function testExists(): void + { + $this->seedProducts(); + + $this->assertTrue($this->table()->where('category', 'widgets')->exists()); + $this->assertFalse($this->table()->where('category', 'nonexistent')->exists()); + } + + public function testDoesntExist(): void + { + $this->seedProducts(); + + $this->assertTrue($this->table()->where('category', 'nonexistent')->doesntExist()); + $this->assertFalse($this->table()->where('category', 'widgets')->doesntExist()); + } + + public function testInsert(): void + { + $this->seedProducts(); + + $result = $this->table()->insert([ + 'name' => 'New Product', + 'category' => 'new', + 'price' => 9.99, + 'stock' => 5, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertTrue($result); + $this->assertSame(6, $this->table()->count()); + } + + public function testInsertGetId(): void + { + $this->seedProducts(); + + $id = $this->table()->insertGetId([ + 'name' => 'Another Product', + 'category' => 'another', + 'price' => 14.99, + 'stock' => 3, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertIsInt($id); + $this->assertNotNull($this->table()->find($id)); + } + + public function testUpdate(): void + { + $this->seedProducts(); + + $affected = $this->table()->where('category', 'widgets')->update(['stock' => 200]); + + $this->assertSame(2, $affected); + + $products = $this->table()->where('category', 'widgets')->get(); + foreach ($products as $product) { + $this->assertEquals(200, $product->stock); + } + } + + public function testIncrement(): void + { + $this->seedProducts(); + + $this->table()->where('name', 'Widget A')->increment('stock', 10); + + $product = $this->table()->where('name', 'Widget A')->first(); + $this->assertEquals(110, $product->stock); + } + + public function testDecrement(): void + { + $this->seedProducts(); + + $this->table()->where('name', 'Widget A')->decrement('stock', 10); + + $product = $this->table()->where('name', 'Widget A')->first(); + $this->assertEquals(90, $product->stock); + } + + public function testDelete(): void + { + $this->seedProducts(); + + $affected = $this->table()->where('active', false)->delete(); + + $this->assertSame(1, $affected); + $this->assertSame(4, $this->table()->count()); + } + + public function testTruncate(): void + { + $this->seedProducts(); + + $this->table()->truncate(); + + $this->assertSame(0, $this->table()->count()); + } + + public function testChunk(): void + { + $this->seedProducts(); + + $processed = 0; + + $this->table()->orderBy('id')->chunk(2, function ($products) use (&$processed) { + $processed += $products->count(); + }); + + $this->assertSame(5, $processed); + } + + public function testGroupBy(): void + { + $this->seedProducts(); + + $categories = $this->table() + ->select('category', DB::connection($this->driver)->raw('COUNT(*) as count')) + ->groupBy('category') + ->get(); + + $this->assertCount(3, $categories); + } + + public function testHaving(): void + { + $this->seedProducts(); + + $categories = $this->table() + ->select('category', DB::connection($this->driver)->raw('SUM(stock) as total_stock')) + ->groupBy('category') + ->havingRaw('SUM(stock) > ?', [50]) + ->get(); + + $this->assertCount(1, $categories); + $this->assertSame('widgets', $categories->first()->category); + } + + public function testDistinct(): void + { + $this->seedProducts(); + + $categories = $this->table()->distinct()->pluck('category'); + + $this->assertCount(3, $categories); + } + + public function testWhen(): void + { + $this->seedProducts(); + + $filterCategory = 'widgets'; + + $products = $this->table() + ->when($filterCategory, function ($query, $category) { + return $query->where('category', $category); + }) + ->get(); + + $this->assertCount(2, $products); + + $products = $this->table() + ->when(null, function ($query, $category) { + return $query->where('category', $category); + }) + ->get(); + + $this->assertCount(5, $products); + } + + public function testUnless(): void + { + $this->seedProducts(); + + $showAll = false; + + $products = $this->table() + ->unless($showAll, function ($query) { + return $query->where('active', true); + }) + ->get(); + + $this->assertCount(4, $products); + } + + public function testToSql(): void + { + $this->seedProducts(); + + $sql = $this->table()->where('category', 'widgets')->toSql(); + + $this->assertStringContainsString('select', strtolower($sql)); + $this->assertStringContainsString('where', strtolower($sql)); + } +} diff --git a/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php b/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php new file mode 100644 index 000000000..27b2b725e --- /dev/null +++ b/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php @@ -0,0 +1,486 @@ +configureDatabase(); + $this->createTestTable(); + + // Suppress expected error logs from transaction rollback tests + $config = $this->app->get(ConfigInterface::class); + $config->set('Hyperf\Contract\StdoutLoggerInterface.log_level', []); + } + + protected function configureDatabase(): void + { + $config = $this->app->get(ConfigInterface::class); + + $this->app->set('db.connector.sqlite', new SQLiteConnector()); + + $connectionConfig = [ + 'driver' => 'sqlite', + 'database' => self::$databasePath, + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 5, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 60.0, + ], + ]; + + $config->set('databases.pool_test', $connectionConfig); + $config->set('database.connections.pool_test', $connectionConfig); + } + + protected function createTestTable(): void + { + Schema::connection('pool_test')->dropIfExists('pool_mgmt_test'); + Schema::connection('pool_test')->create('pool_mgmt_test', function ($table) { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + } + + protected function getPoolFactory(): PoolFactory + { + return $this->app->get(PoolFactory::class); + } + + protected function getPooledConnection(): PooledConnection + { + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('pool_test'); + + return $pool->get(); + } + + // ========================================================================= + // DB-01: Nested transaction rollback on release + // ========================================================================= + + /** + * Test that releasing a connection with open transaction rolls back completely. + * + * This verifies the fix for DB-01: rollBack(0) is called instead of rollBack() + * to fully exit all transaction levels. + */ + public function testReleasingConnectionWithOpenTransactionRollsBack(): void + { + run(function () { + $pooled = $this->getPooledConnection(); + $connection = $pooled->getConnection(); + + // Start a transaction and insert data (don't commit) + $connection->beginTransaction(); + $connection->table('pool_mgmt_test')->insert([ + 'name' => 'Should be rolled back', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertEquals(1, $connection->transactionLevel()); + + // Release without committing - should trigger rollback + $pooled->release(); + }); + + // Verify the data was rolled back + run(function () { + $pooled = $this->getPooledConnection(); + $connection = $pooled->getConnection(); + + $count = $connection->table('pool_mgmt_test') + ->where('name', 'Should be rolled back') + ->count(); + + $this->assertEquals(0, $count, 'Data should be rolled back when connection released with open transaction'); + + $pooled->release(); + }); + } + + /** + * Test that nested transactions are fully rolled back on release. + * + * This is the critical test for DB-01: ensures rollBack(0) is used to + * exit ALL transaction levels, not just one. + */ + public function testNestedTransactionsAreFullyRolledBackOnRelease(): void + { + run(function () { + $pooled = $this->getPooledConnection(); + $connection = $pooled->getConnection(); + + // Create nested transactions + $connection->beginTransaction(); // Level 1 + $connection->table('pool_mgmt_test')->insert([ + 'name' => 'Level 1 data', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $connection->beginTransaction(); // Level 2 (savepoint) + $connection->table('pool_mgmt_test')->insert([ + 'name' => 'Level 2 data', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $connection->beginTransaction(); // Level 3 (savepoint) + $connection->table('pool_mgmt_test')->insert([ + 'name' => 'Level 3 data', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertEquals(3, $connection->transactionLevel()); + + // Release without committing any level + $pooled->release(); + }); + + // Verify ALL nested data was rolled back + run(function () { + $pooled = $this->getPooledConnection(); + $connection = $pooled->getConnection(); + + $level1Count = $connection->table('pool_mgmt_test') + ->where('name', 'Level 1 data') + ->count(); + $level2Count = $connection->table('pool_mgmt_test') + ->where('name', 'Level 2 data') + ->count(); + $level3Count = $connection->table('pool_mgmt_test') + ->where('name', 'Level 3 data') + ->count(); + + $this->assertEquals(0, $level1Count, 'Level 1 data should be rolled back'); + $this->assertEquals(0, $level2Count, 'Level 2 data should be rolled back'); + $this->assertEquals(0, $level3Count, 'Level 3 data should be rolled back'); + + // Connection should be clean (no open transactions) + $this->assertEquals(0, $connection->transactionLevel()); + + $pooled->release(); + }); + } + + // ========================================================================= + // DB-02: Pool flush semantics + // ========================================================================= + + /** + * Test that flushPool closes all connections in the pool. + */ + public function testFlushPoolClosesAllConnections(): void + { + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('pool_test'); + + // Get and release a few connections to populate the pool + run(function () use ($pool) { + $connections = []; + for ($i = 0; $i < 3; ++$i) { + $connections[] = $pool->get(); + } + foreach ($connections as $conn) { + $conn->release(); + } + }); + + $connectionsBeforeFlush = $pool->getCurrentConnections(); + $this->assertGreaterThan(0, $connectionsBeforeFlush, 'Pool should have connections before flush'); + + // Flush the pool + $factory->flushPool('pool_test'); + + // Pool should be removed from factory + // Getting pool again should create a fresh one + $newPool = $factory->getPool('pool_test'); + $this->assertEquals(0, $newPool->getCurrentConnections(), 'Fresh pool should have no connections'); + } + + /** + * Test that flushAll closes all connections in all pools. + */ + public function testFlushAllClosesAllPoolConnections(): void + { + $factory = $this->getPoolFactory(); + + // Get pool and create some connections + $pool = $factory->getPool('pool_test'); + + run(function () use ($pool) { + $conn = $pool->get(); + $conn->release(); + }); + + $this->assertGreaterThan(0, $pool->getCurrentConnections()); + + // Flush all pools + $factory->flushAll(); + + // Getting pool again should give fresh pool + $newPool = $factory->getPool('pool_test'); + $this->assertEquals(0, $newPool->getCurrentConnections()); + } + + // ========================================================================= + // DB-03: DatabaseManager disconnect/reconnect/purge + // ========================================================================= + + /** + * Test that disconnect() nulls PDOs on existing connection in context. + */ + public function testDisconnectNullsPdosOnExistingConnection(): void + { + run(function () { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + + // Get a connection (puts it in context) + $connection = $manager->connection('pool_test'); + $this->assertInstanceOf(Connection::class, $connection); + + // Verify PDO is set + $this->assertNotNull($connection->getPdo()); + + // Disconnect + $manager->disconnect('pool_test'); + + // PDO should now be null (will reconnect on next use) + // We can't directly check getPdo() as it auto-reconnects, + // but we can verify disconnect was called by checking the method works + $this->assertTrue(true, 'Disconnect completed without error'); + }); + } + + /** + * Test that disconnect() does nothing if no connection exists in context. + */ + public function testDisconnectDoesNothingWithoutExistingConnection(): void + { + run(function () { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + + // Clear any existing connection from context + $contextKey = 'database.connection.pool_test'; + Context::destroy($contextKey); + + // This should not throw + $manager->disconnect('pool_test'); + + $this->assertTrue(true, 'Disconnect without existing connection should not throw'); + }); + } + + /** + * Test that reconnect() returns existing connection after reconnecting it. + */ + public function testReconnectReconnectsExistingConnection(): void + { + run(function () { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + + // Get initial connection + $connection1 = $manager->connection('pool_test'); + + // Reconnect + $connection2 = $manager->reconnect('pool_test'); + + // Should be the same connection instance (from context) + $this->assertSame($connection1, $connection2); + + // Should have working PDO + $this->assertNotNull($connection2->getPdo()); + }); + } + + /** + * Test that reconnect() gets fresh connection if none exists. + */ + public function testReconnectGetsFreshConnectionWhenNoneExists(): void + { + run(function () { + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + + // Clear any existing connection from context + $contextKey = 'database.connection.pool_test'; + Context::destroy($contextKey); + + // Reconnect should get a fresh connection + $connection = $manager->reconnect('pool_test'); + + $this->assertInstanceOf(Connection::class, $connection); + $this->assertNotNull($connection->getPdo()); + }); + } + + /** + * Test that purge() flushes the pool. + * + * Note: We test purge by verifying the pool is flushed after calling purge. + * The context clearing is tested implicitly - if context wasn't cleared, + * the old connection would still be returned. + */ + public function testPurgeFlushesPool(): void + { + $factory = $this->getPoolFactory(); + + // First, populate the pool with some connections + run(function () { + $pooled1 = $this->getPooledConnection(); + $pooled2 = $this->getPooledConnection(); + $pooled1->release(); + $pooled2->release(); + }); + + // Pool should have connections now + $pool = $factory->getPool('pool_test'); + $connectionsBefore = $pool->getCurrentConnections(); + $this->assertGreaterThan(0, $connectionsBefore, 'Pool should have connections before purge'); + + // Purge + /** @var DatabaseManager $manager */ + $manager = $this->app->get(DatabaseManager::class); + $manager->purge('pool_test'); + + // Pool should be flushed (getting pool again gives fresh one with no connections) + $newPool = $factory->getPool('pool_test'); + $this->assertEquals(0, $newPool->getCurrentConnections(), 'Pool should be empty after purge'); + } + + // ========================================================================= + // DB-04: ConnectionEstablished event + // ========================================================================= + + /** + * Test that ConnectionEstablished event is dispatched when pooled connection is created. + */ + public function testConnectionEstablishedEventIsDispatchedForPooledConnection(): void + { + $eventDispatched = false; + $dispatchedConnection = null; + + // Get listener provider and register a listener + /** @var ListenerProvider $listenerProvider */ + $listenerProvider = $this->app->get(ListenerProviderInterface::class); + + $listenerProvider->on( + ConnectionEstablished::class, + function (ConnectionEstablished $event) use (&$eventDispatched, &$dispatchedConnection) { + $eventDispatched = true; + $dispatchedConnection = $event->connection; + } + ); + + // Flush pool to ensure we get a fresh connection (which triggers reconnect) + $factory = $this->getPoolFactory(); + $factory->flushPool('pool_test'); + + run(function () { + $pooled = $this->getPooledConnection(); + // Just getting the connection should trigger the event via reconnect() + $pooled->getConnection(); + $pooled->release(); + }); + + $this->assertTrue($eventDispatched, 'ConnectionEstablished event should be dispatched when pooled connection is created'); + $this->assertInstanceOf(Connection::class, $dispatchedConnection); + } + + /** + * Test that ConnectionEstablished event contains the correct connection name. + */ + public function testConnectionEstablishedEventContainsCorrectConnection(): void + { + $capturedConnectionName = null; + + /** @var ListenerProvider $listenerProvider */ + $listenerProvider = $this->app->get(ListenerProviderInterface::class); + + $listenerProvider->on( + ConnectionEstablished::class, + function (ConnectionEstablished $event) use (&$capturedConnectionName) { + $capturedConnectionName = $event->connection->getName(); + } + ); + + // Flush pool to ensure fresh connection + $factory = $this->getPoolFactory(); + $factory->flushPool('pool_test'); + + run(function () { + $pooled = $this->getPooledConnection(); + $pooled->getConnection(); + $pooled->release(); + }); + + $this->assertEquals('pool_test', $capturedConnectionName); + } +} diff --git a/tests/Integration/Database/Sqlite/SQLiteFilePoolingTest.php b/tests/Integration/Database/Sqlite/SQLiteFilePoolingTest.php new file mode 100644 index 000000000..c5ef2fd20 --- /dev/null +++ b/tests/Integration/Database/Sqlite/SQLiteFilePoolingTest.php @@ -0,0 +1,392 @@ +configureDatabase(); + $this->createTestTable(); + } + + protected function configureDatabase(): void + { + $config = $this->app->get(ConfigInterface::class); + + $this->app->set('db.connector.sqlite', new SQLiteConnector()); + + $connectionConfig = [ + 'driver' => 'sqlite', + 'database' => self::$databasePath, + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 5, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 60.0, + ], + ]; + + $config->set('databases.sqlite_file', $connectionConfig); + $config->set('database.connections.sqlite_file', $connectionConfig); + } + + protected function createTestTable(): void + { + Schema::connection('sqlite_file')->dropIfExists('pool_test_items'); + Schema::connection('sqlite_file')->create('pool_test_items', function ($table) { + $table->id(); + $table->string('name'); + $table->integer('value')->default(0); + $table->timestamps(); + }); + } + + protected function getPooledConnection(): PooledConnection + { + $factory = $this->app->get(PoolFactory::class); + $pool = $factory->getPool('sqlite_file'); + + return $pool->get(); + } + + /** + * Test that data written by one pooled connection is visible to another. + * + * This verifies that file-based SQLite pooling works correctly - all connections + * share the same underlying file. + */ + public function testDataWrittenByOneConnectionIsVisibleToAnother(): void + { + $dataVisibleInCoroutine2 = null; + + run(function () use (&$dataVisibleInCoroutine2) { + // Coroutine 1: Write data + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $connection1->table('pool_test_items')->insert([ + 'name' => 'Written by connection 1', + 'value' => 42, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $pooled1->release(); + usleep(1000); + + // Coroutine 2: Read data written by coroutine 1 + go(function () use (&$dataVisibleInCoroutine2) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $item = $connection2->table('pool_test_items') + ->where('name', 'Written by connection 1') + ->first(); + + $dataVisibleInCoroutine2 = $item; + + $pooled2->release(); + }); + }); + + $this->assertNotNull( + $dataVisibleInCoroutine2, + 'Data written by one pooled connection should be visible to another' + ); + $this->assertEquals(42, $dataVisibleInCoroutine2->value); + } + + /** + * Test that updates from one connection are visible to another. + */ + public function testUpdatesAreVisibleAcrossConnections(): void + { + $updatedValueInCoroutine2 = null; + + run(function () use (&$updatedValueInCoroutine2) { + // Setup: Insert initial data + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $connection1->table('pool_test_items')->insert([ + 'name' => 'Update test item', + 'value' => 100, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Update the value + $connection1->table('pool_test_items') + ->where('name', 'Update test item') + ->update(['value' => 999]); + + $pooled1->release(); + usleep(1000); + + // Coroutine 2: Read the updated value + go(function () use (&$updatedValueInCoroutine2) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $item = $connection2->table('pool_test_items') + ->where('name', 'Update test item') + ->first(); + + $updatedValueInCoroutine2 = $item?->value; + + $pooled2->release(); + }); + }); + + $this->assertEquals( + 999, + $updatedValueInCoroutine2, + 'Updated value should be visible to another pooled connection' + ); + } + + /** + * Test that deletes from one connection affect queries in another. + */ + public function testDeletesAreVisibleAcrossConnections(): void + { + $itemExistsInCoroutine2 = null; + + run(function () use (&$itemExistsInCoroutine2) { + // Setup: Insert and then delete + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $connection1->table('pool_test_items')->insert([ + 'name' => 'Delete test item', + 'value' => 50, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Delete it + $connection1->table('pool_test_items') + ->where('name', 'Delete test item') + ->delete(); + + $pooled1->release(); + usleep(1000); + + // Coroutine 2: Try to find the deleted item + go(function () use (&$itemExistsInCoroutine2) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $item = $connection2->table('pool_test_items') + ->where('name', 'Delete test item') + ->first(); + + $itemExistsInCoroutine2 = $item !== null; + + $pooled2->release(); + }); + }); + + $this->assertFalse( + $itemExistsInCoroutine2, + 'Deleted item should not be visible to another pooled connection' + ); + } + + /** + * Test that committed transactions are visible to other connections. + */ + public function testCommittedTransactionsAreVisibleAcrossConnections(): void + { + $dataVisibleInCoroutine2 = null; + + run(function () use (&$dataVisibleInCoroutine2) { + // Coroutine 1: Insert within a transaction + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $connection1->beginTransaction(); + $connection1->table('pool_test_items')->insert([ + 'name' => 'Transaction item', + 'value' => 777, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $connection1->commit(); + + $pooled1->release(); + usleep(1000); + + // Coroutine 2: Read the committed data + go(function () use (&$dataVisibleInCoroutine2) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $item = $connection2->table('pool_test_items') + ->where('name', 'Transaction item') + ->first(); + + $dataVisibleInCoroutine2 = $item; + + $pooled2->release(); + }); + }); + + $this->assertNotNull( + $dataVisibleInCoroutine2, + 'Committed transaction data should be visible to another pooled connection' + ); + $this->assertEquals(777, $dataVisibleInCoroutine2->value); + } + + /** + * Test that rolled back transactions are NOT visible to other connections. + */ + public function testRolledBackTransactionsAreNotVisibleAcrossConnections(): void + { + $dataVisibleInCoroutine2 = null; + + run(function () use (&$dataVisibleInCoroutine2) { + // Coroutine 1: Insert within a transaction, then rollback + $pooled1 = $this->getPooledConnection(); + $connection1 = $pooled1->getConnection(); + + $connection1->beginTransaction(); + $connection1->table('pool_test_items')->insert([ + 'name' => 'Rollback item', + 'value' => 888, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $connection1->rollBack(); + + $pooled1->release(); + usleep(1000); + + // Coroutine 2: Try to find the rolled back data + go(function () use (&$dataVisibleInCoroutine2) { + $pooled2 = $this->getPooledConnection(); + $connection2 = $pooled2->getConnection(); + + $item = $connection2->table('pool_test_items') + ->where('name', 'Rollback item') + ->first(); + + $dataVisibleInCoroutine2 = $item; + + $pooled2->release(); + }); + }); + + $this->assertNull( + $dataVisibleInCoroutine2, + 'Rolled back transaction data should NOT be visible to another pooled connection' + ); + } + + /** + * Test concurrent writes from multiple pooled connections. + */ + public function testConcurrentWritesFromMultipleConnections(): void + { + $totalCount = null; + + run(function () use (&$totalCount) { + // Launch multiple coroutines that each write data + $coroutineCount = 3; + $itemsPerCoroutine = 5; + + for ($c = 1; $c <= $coroutineCount; ++$c) { + go(function () use ($c, $itemsPerCoroutine) { + $pooled = $this->getPooledConnection(); + $connection = $pooled->getConnection(); + + for ($i = 1; $i <= $itemsPerCoroutine; ++$i) { + $connection->table('pool_test_items')->insert([ + 'name' => "Coroutine {$c} Item {$i}", + 'value' => ($c * 100) + $i, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $pooled->release(); + }); + } + }); + + // Small delay to ensure all coroutines complete + usleep(10000); + + run(function () use (&$totalCount) { + // Verify all items were written + $pooled = $this->getPooledConnection(); + $connection = $pooled->getConnection(); + + $totalCount = $connection->table('pool_test_items')->count(); + + $pooled->release(); + }); + + $this->assertEquals( + 15, // 3 coroutines * 5 items each + $totalCount, + 'All items from concurrent coroutines should be persisted' + ); + } +} diff --git a/tests/Integration/Database/Sqlite/SqliteTestCase.php b/tests/Integration/Database/Sqlite/SqliteTestCase.php new file mode 100644 index 000000000..a66b62730 --- /dev/null +++ b/tests/Integration/Database/Sqlite/SqliteTestCase.php @@ -0,0 +1,13 @@ +id(); + $table->string('name'); + $table->decimal('balance', 10, 2)->default(0); + $table->timestamps(); + }); + + Schema::create('tx_transfers', function (Blueprint $table) { + $table->id(); + $table->foreignId('from_account_id')->constrained('tx_accounts'); + $table->foreignId('to_account_id')->constrained('tx_accounts'); + $table->decimal('amount', 10, 2); + $table->timestamps(); + }); + } + + protected function conn(): ConnectionInterface + { + return DB::connection($this->driver); + } + + public function testBasicTransaction(): void + { + $this->conn()->transaction(function () { + TxAccount::create(['name' => 'Account 1', 'balance' => 100]); + TxAccount::create(['name' => 'Account 2', 'balance' => 200]); + }); + + $this->assertSame(2, TxAccount::count()); + } + + public function testTransactionRollbackOnException(): void + { + try { + $this->conn()->transaction(function () { + TxAccount::create(['name' => 'Will Be Rolled Back', 'balance' => 100]); + + throw new RuntimeException('Something went wrong'); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertSame(0, TxAccount::count()); + } + + public function testTransactionReturnsValue(): void + { + $result = $this->conn()->transaction(function () { + $account = TxAccount::create(['name' => 'Return Test', 'balance' => 500]); + + return $account->id; + }); + + $this->assertNotNull($result); + $this->assertNotNull(TxAccount::find($result)); + } + + public function testManualBeginCommit(): void + { + $this->conn()->beginTransaction(); + + TxAccount::create(['name' => 'Manual 1', 'balance' => 100]); + TxAccount::create(['name' => 'Manual 2', 'balance' => 200]); + + $this->conn()->commit(); + + $this->assertSame(2, TxAccount::count()); + } + + public function testManualBeginRollback(): void + { + $this->conn()->beginTransaction(); + + TxAccount::create(['name' => 'Rollback 1', 'balance' => 100]); + TxAccount::create(['name' => 'Rollback 2', 'balance' => 200]); + + $this->conn()->rollBack(); + + $this->assertSame(0, TxAccount::count()); + } + + public function testNestedTransactions(): void + { + $this->conn()->transaction(function () { + TxAccount::create(['name' => 'Outer', 'balance' => 100]); + + $this->conn()->transaction(function () { + TxAccount::create(['name' => 'Inner', 'balance' => 200]); + }); + }); + + $this->assertSame(2, TxAccount::count()); + } + + public function testNestedTransactionRollback(): void + { + try { + $this->conn()->transaction(function () { + TxAccount::create(['name' => 'Outer OK', 'balance' => 100]); + + $this->conn()->transaction(function () { + TxAccount::create(['name' => 'Inner Will Fail', 'balance' => 200]); + + throw new RuntimeException('Inner failed'); + }); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertSame(0, TxAccount::count()); + } + + public function testTransactionLevel(): void + { + $baseLevel = $this->conn()->transactionLevel(); + + $this->conn()->beginTransaction(); + $this->assertSame($baseLevel + 1, $this->conn()->transactionLevel()); + + $this->conn()->beginTransaction(); + $this->assertSame($baseLevel + 2, $this->conn()->transactionLevel()); + + $this->conn()->rollBack(); + $this->assertSame($baseLevel + 1, $this->conn()->transactionLevel()); + + $this->conn()->rollBack(); + $this->assertSame($baseLevel, $this->conn()->transactionLevel()); + } + + public function testTransferBetweenAccounts(): void + { + $account1 = TxAccount::create(['name' => 'Account A', 'balance' => 1000]); + $account2 = TxAccount::create(['name' => 'Account B', 'balance' => 500]); + + $this->conn()->transaction(function () use ($account1, $account2) { + $amount = 300; + + $account1->decrement('balance', $amount); + $account2->increment('balance', $amount); + + TxTransfer::create([ + 'from_account_id' => $account1->id, + 'to_account_id' => $account2->id, + 'amount' => $amount, + ]); + }); + + $this->assertEquals(700, $account1->fresh()->balance); + $this->assertEquals(800, $account2->fresh()->balance); + $this->assertSame(1, TxTransfer::count()); + } + + public function testTransferRollbackOnInsufficientFunds(): void + { + $account1 = TxAccount::create(['name' => 'Poor Account', 'balance' => 100]); + $account2 = TxAccount::create(['name' => 'Rich Account', 'balance' => 5000]); + + try { + $this->conn()->transaction(function () use ($account1, $account2) { + $amount = 500; + + if ($account1->balance < $amount) { + throw new RuntimeException('Insufficient funds'); + } + + $account1->decrement('balance', $amount); + $account2->increment('balance', $amount); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertEquals(100, $account1->fresh()->balance); + $this->assertEquals(5000, $account2->fresh()->balance); + } + + public function testTransactionWithAttempts(): void + { + $attempts = 0; + + $this->conn()->transaction(function () use (&$attempts) { + ++$attempts; + TxAccount::create(['name' => 'Attempts Test', 'balance' => 100]); + }, 3); + + $this->assertSame(1, $attempts); + $this->assertSame(1, TxAccount::count()); + } + + public function testTransactionCallbackReceivesAttemptNumber(): void + { + $results = []; + + for ($i = 1; $i <= 3; ++$i) { + $result = $this->conn()->transaction(function () use ($i) { + return TxAccount::create(['name' => "Batch {$i}", 'balance' => $i * 100]); + }); + $results[] = $result; + } + + $this->assertCount(3, $results); + $this->assertSame(3, TxAccount::count()); + } + + public function testQueryBuilderInTransaction(): void + { + $this->conn()->transaction(function () { + $this->conn()->table('tx_accounts')->insert([ + 'name' => 'Query Builder Insert', + 'balance' => 999, + 'created_at' => now(), + 'updated_at' => now(), + ]); + }); + + $account = TxAccount::where('name', 'Query Builder Insert')->first(); + $this->assertNotNull($account); + $this->assertEquals(999, $account->balance); + } + + public function testBulkOperationsInTransaction(): void + { + $this->conn()->transaction(function () { + for ($i = 1; $i <= 100; ++$i) { + TxAccount::create(['name' => "Bulk Account {$i}", 'balance' => $i]); + } + }); + + $this->assertSame(100, TxAccount::count()); + $this->assertEquals(5050, TxAccount::sum('balance')); + } + + public function testUpdateInTransaction(): void + { + TxAccount::create(['name' => 'Update Test', 'balance' => 100]); + + $this->conn()->transaction(function () { + TxAccount::where('name', 'Update Test')->update(['balance' => 999]); + }); + + $this->assertEquals(999, TxAccount::where('name', 'Update Test')->first()->balance); + } + + public function testDeleteInTransaction(): void + { + TxAccount::create(['name' => 'Delete Test 1', 'balance' => 100]); + TxAccount::create(['name' => 'Delete Test 2', 'balance' => 200]); + TxAccount::create(['name' => 'Keep This', 'balance' => 300]); + + $this->conn()->transaction(function () { + TxAccount::where('name', 'like', 'Delete Test%')->delete(); + }); + + $this->assertSame(1, TxAccount::count()); + $this->assertSame('Keep This', TxAccount::first()->name); + } +} + +class TxAccount extends Model +{ + protected ?string $table = 'tx_accounts'; + + protected array $fillable = ['name', 'balance']; +} + +class TxTransfer extends Model +{ + protected ?string $table = 'tx_transfers'; + + protected array $fillable = ['from_account_id', 'to_account_id', 'amount']; +} diff --git a/tests/JWT/Storage/TaggedCacheTest.php b/tests/JWT/Storage/TaggedCacheTest.php index 7e407a416..2ecc01659 100644 --- a/tests/JWT/Storage/TaggedCacheTest.php +++ b/tests/JWT/Storage/TaggedCacheTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\JWT\Storage; -use Hypervel\Cache\Contracts\Repository as CacheRepository; +use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\JWT\Storage\TaggedCache; use Hypervel\Tests\TestCase; use Mockery; diff --git a/tests/Log/LogLoggerTest.php b/tests/Log/LogLoggerTest.php index 4c9950162..1a24afc86 100644 --- a/tests/Log/LogLoggerTest.php +++ b/tests/Log/LogLoggerTest.php @@ -20,7 +20,6 @@ class LogLoggerTest extends TestCase { protected function tearDown(): void { - m::close(); Context::destroy('__logger.context'); } diff --git a/tests/Mail/AttachableTest.php b/tests/Mail/AttachableTest.php index 0261b8ad0..d08de4047 100644 --- a/tests/Mail/AttachableTest.php +++ b/tests/Mail/AttachableTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Mail; +use Hypervel\Contracts\Mail\Attachable; use Hypervel\Mail\Attachment; -use Hypervel\Mail\Contracts\Attachable; use Hypervel\Mail\Mailable; use PHPUnit\Framework\TestCase; diff --git a/tests/Mail/MailFailoverTransportTest.php b/tests/Mail/MailFailoverTransportTest.php index b5dca93de..3db256c5e 100644 --- a/tests/Mail/MailFailoverTransportTest.php +++ b/tests/Mail/MailFailoverTransportTest.php @@ -10,7 +10,7 @@ use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; use Hyperf\ViewEngine\Contract\FactoryInterface as ViewInterface; -use Hypervel\Mail\Contracts\Factory as FactoryContract; +use Hypervel\Contracts\Mail\Factory as FactoryContract; use Hypervel\Mail\MailManager; use Mockery; use PHPUnit\Framework\TestCase; diff --git a/tests/Mail/MailLogTransportTest.php b/tests/Mail/MailLogTransportTest.php index 97fd97713..ccc6d9149 100644 --- a/tests/Mail/MailLogTransportTest.php +++ b/tests/Mail/MailLogTransportTest.php @@ -10,8 +10,8 @@ use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; use Hyperf\ViewEngine\Contract\FactoryInterface as ViewInterface; +use Hypervel\Contracts\Mail\Factory as FactoryContract; use Hypervel\Mail\Attachment; -use Hypervel\Mail\Contracts\Factory as FactoryContract; use Hypervel\Mail\MailManager; use Hypervel\Mail\Message; use Hypervel\Mail\Transport\LogTransport; diff --git a/tests/Mail/MailMailableTest.php b/tests/Mail/MailMailableTest.php index 1f9adced0..77eff0d8c 100644 --- a/tests/Mail/MailMailableTest.php +++ b/tests/Mail/MailMailableTest.php @@ -10,10 +10,10 @@ use Hyperf\Di\Definition\DefinitionSource; use Hyperf\ViewEngine\Contract\FactoryInterface as ViewFactory; use Hyperf\ViewEngine\Contract\ViewInterface; +use Hypervel\Contracts\Mail\Attachable; +use Hypervel\Contracts\Mail\Factory as FactoryContract; +use Hypervel\Contracts\Mail\Mailer as MailerContract; use Hypervel\Mail\Attachment; -use Hypervel\Mail\Contracts\Attachable; -use Hypervel\Mail\Contracts\Factory as FactoryContract; -use Hypervel\Mail\Contracts\Mailer as MailerContract; use Hypervel\Mail\Mailable; use Hypervel\Mail\Mailables\Envelope; use Hypervel\Mail\Mailables\Headers; @@ -31,11 +31,6 @@ */ class MailMailableTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testMailableSetsRecipientsCorrectly() { $this->mockContainer(); diff --git a/tests/Mail/MailMailerTest.php b/tests/Mail/MailMailerTest.php index 13d5d0d2b..832da7543 100644 --- a/tests/Mail/MailMailerTest.php +++ b/tests/Mail/MailMailerTest.php @@ -37,8 +37,6 @@ protected function setUp(): void protected function tearDown(): void { unset($_SERVER['__mailer.test']); - - m::close(); } public function testMailerSendSendsMessageWithProperViewContent() diff --git a/tests/Mail/MailMarkdownTest.php b/tests/Mail/MailMarkdownTest.php index c679a4d1c..82baacc4e 100644 --- a/tests/Mail/MailMarkdownTest.php +++ b/tests/Mail/MailMarkdownTest.php @@ -16,11 +16,6 @@ */ class MailMarkdownTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testRenderFunctionReturnsHtml() { $viewInterface = m::mock(ViewInterface::class); diff --git a/tests/Mail/MailMessageTest.php b/tests/Mail/MailMessageTest.php index 1ce056ce5..5dec68424 100644 --- a/tests/Mail/MailMessageTest.php +++ b/tests/Mail/MailMessageTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\Mail; -use Hyperf\Stringable\Str; +use Hypervel\Contracts\Mail\Attachable; use Hypervel\Mail\Attachment; -use Hypervel\Mail\Contracts\Attachable; use Hypervel\Mail\Message; +use Hypervel\Support\Str; use PHPUnit\Framework\TestCase; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; diff --git a/tests/Mail/MailSesTransportTest.php b/tests/Mail/MailSesTransportTest.php index d638aceab..84a987379 100644 --- a/tests/Mail/MailSesTransportTest.php +++ b/tests/Mail/MailSesTransportTest.php @@ -33,8 +33,6 @@ class MailSesTransportTest extends TestCase { protected function tearDown(): void { - m::close(); - parent::tearDown(); } diff --git a/tests/Mail/MailSesV2TransportTest.php b/tests/Mail/MailSesV2TransportTest.php index 1f3eaddcc..1a708e2fe 100644 --- a/tests/Mail/MailSesV2TransportTest.php +++ b/tests/Mail/MailSesV2TransportTest.php @@ -31,8 +31,6 @@ class MailSesV2TransportTest extends TestCase { protected function tearDown(): void { - m::close(); - parent::tearDown(); } diff --git a/tests/Mail/MailableQueuedTest.php b/tests/Mail/MailableQueuedTest.php index def875a02..1fa69ba42 100644 --- a/tests/Mail/MailableQueuedTest.php +++ b/tests/Mail/MailableQueuedTest.php @@ -11,13 +11,13 @@ use Hyperf\Di\Definition\DefinitionSource; use Hyperf\ViewEngine\Factory; use Hypervel\Bus\Queueable; +use Hypervel\Contracts\Mail\Mailable as MailableContract; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Filesystem\Filesystem; use Hypervel\Filesystem\FilesystemManager; -use Hypervel\Mail\Contracts\Mailable as MailableContract; use Hypervel\Mail\Mailable; use Hypervel\Mail\Mailer; use Hypervel\Mail\SendQueuedMailable; -use Hypervel\Queue\Contracts\ShouldQueue; use Hypervel\Support\Testing\Fakes\QueueFake; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -29,11 +29,6 @@ */ class MailableQueuedTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testQueuedMailableSent() { $queueFake = new QueueFake($this->getContainer()); diff --git a/tests/NestedSet/Models/Category.php b/tests/NestedSet/Models/Category.php index fb6b45e55..c065ad90a 100644 --- a/tests/NestedSet/Models/Category.php +++ b/tests/NestedSet/Models/Category.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\NestedSet\Models; -use Hyperf\Database\Model\SoftDeletes; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\NestedSet\HasNode; class Category extends Model diff --git a/tests/NestedSet/NodeTest.php b/tests/NestedSet/NodeTest.php index f2059e9ba..fe0133476 100644 --- a/tests/NestedSet/NodeTest.php +++ b/tests/NestedSet/NodeTest.php @@ -6,11 +6,11 @@ use BadMethodCallException; use Carbon\Carbon; -use Hyperf\Collection\Collection as HyperfCollection; -use Hyperf\Database\Exception\QueryException; -use Hyperf\Database\Model\ModelNotFoundException; +use Hypervel\Database\Eloquent\ModelNotFoundException; +use Hypervel\Database\QueryException; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\NestedSet\Eloquent\Collection; +use Hypervel\Support\Collection as BaseCollection; use Hypervel\Support\Facades\DB; use Hypervel\Testbench\TestCase; use Hypervel\Tests\NestedSet\Models\Category; @@ -42,6 +42,11 @@ public function setUp(): void DB::table('categories') ->insert($this->getMockCategories()); + + // Reset Postgres sequence after inserting with explicit IDs + if (DB::connection()->getDriverName() === 'pgsql') { + DB::statement("SELECT setval('categories_id_seq', (SELECT MAX(id) FROM categories))"); + } } protected function getMockCategories(): array @@ -474,7 +479,7 @@ public function testToTreeBuildsWithDefaultOrder(): void public function testToTreeBuildsWithCustomOrder(): void { $tree = Category::whereBetween('_lft', [8, 17]) - ->orderBy('title') + ->orderBy('name') ->get() ->toTree(); @@ -994,7 +999,7 @@ public function testReplication(): void $this->assertEquals(1, $category->getParentId()); } - protected function getAll(array|HyperfCollection $items): array + protected function getAll(array|BaseCollection $items): array { return is_array($items) ? $items : $items->all(); } diff --git a/tests/NestedSet/ScopedNodeTest.php b/tests/NestedSet/ScopedNodeTest.php index 8e14aa910..b50a0ea26 100644 --- a/tests/NestedSet/ScopedNodeTest.php +++ b/tests/NestedSet/ScopedNodeTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\NestedSet; -use Hyperf\Database\Model\ModelNotFoundException; +use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Support\Facades\DB; use Hypervel\Testbench\TestCase; @@ -37,6 +37,11 @@ public function setUp(): void DB::table('menu_items') ->insert($this->getMockMenuItems()); + + // Reset Postgres sequence after inserting with explicit IDs + if (DB::connection()->getDriverName() === 'pgsql') { + DB::statement("SELECT setval('menu_items_id_seq', (SELECT MAX(id) FROM menu_items))"); + } } protected function getMockMenuItems(): array diff --git a/tests/NestedSet/migrations/2025_07_02_000000_create_categories_table.php b/tests/NestedSet/migrations/2025_07_02_000000_create_categories_table.php index f48d0c6c1..aa581b1e1 100644 --- a/tests/NestedSet/migrations/2025_07_02_000000_create_categories_table.php +++ b/tests/NestedSet/migrations/2025_07_02_000000_create_categories_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\NestedSet\NestedSet; use Hypervel\Support\Facades\Schema; diff --git a/tests/NestedSet/migrations/2025_07_03_000000_create_menu_items_table.php b/tests/NestedSet/migrations/2025_07_03_000000_create_menu_items_table.php index 24808e8b3..add636a5b 100644 --- a/tests/NestedSet/migrations/2025_07_03_000000_create_menu_items_table.php +++ b/tests/NestedSet/migrations/2025_07_03_000000_create_menu_items_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\NestedSet\NestedSet; use Hypervel\Support\Facades\Schema; diff --git a/tests/Notifications/NotificationBroadcastChannelTest.php b/tests/Notifications/NotificationBroadcastChannelTest.php index 88331df24..902f8cdaa 100644 --- a/tests/Notifications/NotificationBroadcastChannelTest.php +++ b/tests/Notifications/NotificationBroadcastChannelTest.php @@ -19,11 +19,6 @@ */ class NotificationBroadcastChannelTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testDatabaseChannelCreatesDatabaseRecordWithProperData() { $notification = new NotificationBroadcastChannelTestNotification(); diff --git a/tests/Notifications/NotificationChannelManagerTest.php b/tests/Notifications/NotificationChannelManagerTest.php index 4ed45f191..eeb9938aa 100644 --- a/tests/Notifications/NotificationChannelManagerTest.php +++ b/tests/Notifications/NotificationChannelManagerTest.php @@ -8,9 +8,10 @@ use Hyperf\Contract\ConfigInterface; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Bus\Contracts\Dispatcher as BusDispatcherContract; use Hypervel\Bus\Queueable; use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Notifications\ChannelManager; use Hypervel\Notifications\Channels\MailChannel; use Hypervel\Notifications\Events\NotificationSending; @@ -21,7 +22,6 @@ use Hypervel\Notifications\SendQueuedNotifications; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\ObjectPool\PoolManager; -use Hypervel\Queue\Contracts\ShouldQueue; use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; @@ -32,11 +32,6 @@ */ class NotificationChannelManagerTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testGetDefaultChannel() { $container = $this->getContainer(); diff --git a/tests/Notifications/NotificationDatabaseChannelTest.php b/tests/Notifications/NotificationDatabaseChannelTest.php index f63cc795a..8cec8e037 100644 --- a/tests/Notifications/NotificationDatabaseChannelTest.php +++ b/tests/Notifications/NotificationDatabaseChannelTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Notifications; -use Hyperf\Database\Model\Model; +use Hypervel\Database\Eloquent\Model; use Hypervel\Notifications\Channels\DatabaseChannel; use Hypervel\Notifications\Messages\DatabaseMessage; use Hypervel\Notifications\Notification; @@ -17,11 +17,6 @@ */ class NotificationDatabaseChannelTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testDatabaseChannelCreatesDatabaseRecordWithProperData() { $notification = new NotificationDatabaseChannelTestNotification(); diff --git a/tests/Notifications/NotificationMailMessageTest.php b/tests/Notifications/NotificationMailMessageTest.php index a6250a4a9..f1451e822 100644 --- a/tests/Notifications/NotificationMailMessageTest.php +++ b/tests/Notifications/NotificationMailMessageTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Notifications; +use Hypervel\Contracts\Mail\Attachable; use Hypervel\Mail\Attachment; -use Hypervel\Mail\Contracts\Attachable; use Hypervel\Notifications\Messages\MailMessage; use PHPUnit\Framework\TestCase; diff --git a/tests/Notifications/NotificationRoutesNotificationsTest.php b/tests/Notifications/NotificationRoutesNotificationsTest.php index f03dcd1b2..38876c7a4 100644 --- a/tests/Notifications/NotificationRoutesNotificationsTest.php +++ b/tests/Notifications/NotificationRoutesNotificationsTest.php @@ -7,8 +7,8 @@ use Hyperf\Context\ApplicationContext; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; +use Hypervel\Contracts\Notifications\Dispatcher; use Hypervel\Notifications\AnonymousNotifiable; -use Hypervel\Notifications\Contracts\Dispatcher; use Hypervel\Notifications\RoutesNotifications; use InvalidArgumentException; use Mockery as m; @@ -21,11 +21,6 @@ */ class NotificationRoutesNotificationsTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testNotificationCanBeDispatched() { $container = $this->getContainer(); diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index ffb2e78dc..e051dd39c 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -4,14 +4,14 @@ namespace Hypervel\Tests\Notifications; -use Hypervel\Bus\Contracts\Dispatcher as BusDispatcherContract; use Hypervel\Bus\Queueable; +use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Notifications\AnonymousNotifiable; use Hypervel\Notifications\ChannelManager; use Hypervel\Notifications\Notifiable; use Hypervel\Notifications\Notification; use Hypervel\Notifications\NotificationSender; -use Hypervel\Queue\Contracts\ShouldQueue; use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface as EventDispatcher; @@ -22,11 +22,6 @@ */ class NotificationSenderTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testItCanSendNotificationsWithAStringVia() { $notifiable = m::mock(Notifiable::class); diff --git a/tests/Notifications/SlackMessageTest.php b/tests/Notifications/SlackMessageTest.php index bb29887c1..5ed256010 100644 --- a/tests/Notifications/SlackMessageTest.php +++ b/tests/Notifications/SlackMessageTest.php @@ -46,8 +46,6 @@ public function tearDown(): void $this->slackChannel = null; $this->client = null; $this->config = null; - - Mockery::close(); } public function testExceptionWhenNoTextOrBlock(): void diff --git a/tests/Permission/Middlewares/PermissionMiddlewareTest.php b/tests/Permission/Middlewares/PermissionMiddlewareTest.php index 644386b17..017ae8d0f 100644 --- a/tests/Permission/Middlewares/PermissionMiddlewareTest.php +++ b/tests/Permission/Middlewares/PermissionMiddlewareTest.php @@ -54,7 +54,6 @@ protected function setUp(): void protected function tearDown(): void { - m::close(); parent::tearDown(); } diff --git a/tests/Permission/Middlewares/RoleMiddlewareTest.php b/tests/Permission/Middlewares/RoleMiddlewareTest.php index 79e4499e4..3d9973feb 100644 --- a/tests/Permission/Middlewares/RoleMiddlewareTest.php +++ b/tests/Permission/Middlewares/RoleMiddlewareTest.php @@ -54,7 +54,6 @@ protected function setUp(): void protected function tearDown(): void { - m::close(); parent::tearDown(); } diff --git a/tests/Permission/Models/User.php b/tests/Permission/Models/User.php index 4ef5aae33..715c64dd2 100644 --- a/tests/Permission/Models/User.php +++ b/tests/Permission/Models/User.php @@ -6,8 +6,8 @@ use Hypervel\Auth\Access\Authorizable; use Hypervel\Auth\Authenticatable; -use Hypervel\Auth\Contracts\Authenticatable as AuthenticatableContract; -use Hypervel\Auth\Contracts\Authorizable as AuthorizableContract; +use Hypervel\Contracts\Auth\Access\Authorizable as AuthorizableContract; +use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract; use Hypervel\Database\Eloquent\Model; use Hypervel\Permission\Traits\HasRole; diff --git a/tests/Permission/PermissionTestCase.php b/tests/Permission/PermissionTestCase.php index 915569d64..25a0de875 100644 --- a/tests/Permission/PermissionTestCase.php +++ b/tests/Permission/PermissionTestCase.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Permission; use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Testbench\TestCase; diff --git a/tests/Permission/migrations/2025_07_01_000000_create_users_table.php b/tests/Permission/migrations/2025_07_01_000000_create_users_table.php index 35c7a1677..4de02579a 100644 --- a/tests/Permission/migrations/2025_07_01_000000_create_users_table.php +++ b/tests/Permission/migrations/2025_07_01_000000_create_users_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration { diff --git a/tests/Prompts/ProgressTest.php b/tests/Prompts/ProgressTest.php index 04d392eee..5709321ab 100644 --- a/tests/Prompts/ProgressTest.php +++ b/tests/Prompts/ProgressTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Prompts; -use Hyperf\Collection\Collection; use Hypervel\Prompts\Prompt; +use Hypervel\Support\Collection; use PHPUnit\Framework\TestCase; use function Hypervel\Prompts\progress; diff --git a/tests/Prompts/TableTest.php b/tests/Prompts/TableTest.php index 657c5e90e..a63cfd4cd 100644 --- a/tests/Prompts/TableTest.php +++ b/tests/Prompts/TableTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Prompts; -use Hyperf\Collection\Collection; use Hypervel\Prompts\Prompt; +use Hypervel\Support\Collection; use PHPUnit\Framework\TestCase; use function Hypervel\Prompts\table; diff --git a/tests/Queue/DatabaseFailedJobProviderTest.php b/tests/Queue/DatabaseFailedJobProviderTest.php index 401c2d355..8d7e5d48a 100644 --- a/tests/Queue/DatabaseFailedJobProviderTest.php +++ b/tests/Queue/DatabaseFailedJobProviderTest.php @@ -5,11 +5,11 @@ namespace Hypervel\Tests\Queue; use Exception; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Stringable\Str; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Queue\Failed\DatabaseFailedJobProvider; use Hypervel\Support\Carbon; +use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; use RuntimeException; diff --git a/tests/Queue/DatabaseUuidFailedJobProviderTest.php b/tests/Queue/DatabaseUuidFailedJobProviderTest.php index 06db8c04c..6367c2b06 100644 --- a/tests/Queue/DatabaseUuidFailedJobProviderTest.php +++ b/tests/Queue/DatabaseUuidFailedJobProviderTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Queue; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Stringable\Str; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Queue\Failed\DatabaseUuidFailedJobProvider; use Hypervel\Support\Carbon; +use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; use RuntimeException; diff --git a/tests/Queue/FileFailedJobProviderTest.php b/tests/Queue/FileFailedJobProviderTest.php index 664f0811e..64bccff70 100644 --- a/tests/Queue/FileFailedJobProviderTest.php +++ b/tests/Queue/FileFailedJobProviderTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\Queue; use Exception; -use Hyperf\Stringable\Str; use Hypervel\Queue\Failed\FileFailedJobProvider; +use Hypervel\Support\Str; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Queue/InteractsWithQueueTest.php b/tests/Queue/InteractsWithQueueTest.php index 86bec545c..59c42a314 100644 --- a/tests/Queue/InteractsWithQueueTest.php +++ b/tests/Queue/InteractsWithQueueTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Queue; use Exception; -use Hypervel\Queue\Contracts\Job; +use Hypervel\Contracts\Queue\Job; use Hypervel\Queue\InteractsWithQueue; use Mockery as m; use PHPUnit\Framework\TestCase; diff --git a/tests/Queue/PruneBatchesCommandTest.php b/tests/Queue/PruneBatchesCommandTest.php index fe57273d5..fea0fcbbc 100644 --- a/tests/Queue/PruneBatchesCommandTest.php +++ b/tests/Queue/PruneBatchesCommandTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Queue; -use Hypervel\Bus\Contracts\BatchRepository; use Hypervel\Bus\DatabaseBatchRepository; +use Hypervel\Contracts\Bus\BatchRepository; use Hypervel\Queue\Console\PruneBatchesCommand; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -20,8 +20,6 @@ class PruneBatchesCommandTest extends TestCase { protected function tearDown(): void { - m::close(); - parent::tearDown(); } diff --git a/tests/Queue/QueueBeanstalkdJobTest.php b/tests/Queue/QueueBeanstalkdJobTest.php index eda1bcfaf..583d6bf4f 100644 --- a/tests/Queue/QueueBeanstalkdJobTest.php +++ b/tests/Queue/QueueBeanstalkdJobTest.php @@ -24,11 +24,6 @@ */ class QueueBeanstalkdJobTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testFireProperlyCallsTheJobHandler() { $job = $this->getJob(); diff --git a/tests/Queue/QueueBeanstalkdQueueTest.php b/tests/Queue/QueueBeanstalkdQueueTest.php index 2bee591ca..4c61d36fa 100644 --- a/tests/Queue/QueueBeanstalkdQueueTest.php +++ b/tests/Queue/QueueBeanstalkdQueueTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Queue; -use Hyperf\Stringable\Str; use Hypervel\Queue\BeanstalkdQueue; use Hypervel\Queue\Jobs\BeanstalkdJob; +use Hypervel\Support\Str; use Mockery as m; use Pheanstalk\Contract\JobIdInterface; use Pheanstalk\Contract\PheanstalkManagerInterface; @@ -41,8 +41,6 @@ class QueueBeanstalkdQueueTest extends TestCase protected function tearDown(): void { - m::close(); - Uuid::setFactory(new UuidFactory()); } diff --git a/tests/Queue/QueueCoroutineQueueTest.php b/tests/Queue/QueueCoroutineQueueTest.php index 0936a4b6e..39e0589a2 100644 --- a/tests/Queue/QueueCoroutineQueueTest.php +++ b/tests/Queue/QueueCoroutineQueueTest.php @@ -7,9 +7,9 @@ use Exception; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Database\TransactionManager; -use Hypervel\Queue\Contracts\QueueableEntity; -use Hypervel\Queue\Contracts\ShouldQueueAfterCommit; +use Hypervel\Contracts\Queue\QueueableEntity; +use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\CoroutineQueue; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Jobs\SyncJob; @@ -69,10 +69,11 @@ public function testFailedJobGetsHandledWhenAnExceptionIsThrown() public function testItAddsATransactionCallbackForAfterCommitJobs() { $coroutine = new CoroutineQueue(); + $coroutine->setConnectionName('coroutine'); $container = $this->getContainer(); - $transactionManager = m::mock(TransactionManager::class); + $transactionManager = m::mock(DatabaseTransactionsManager::class); $transactionManager->shouldReceive('addCallback')->once()->andReturn(null); - $container->set(TransactionManager::class, $transactionManager); + $container->set(DatabaseTransactionsManager::class, $transactionManager); $coroutine->setContainer($container); run(fn () => $coroutine->push(new CoroutineQueueAfterCommitJob())); @@ -81,10 +82,11 @@ public function testItAddsATransactionCallbackForAfterCommitJobs() public function testItAddsATransactionCallbackForInterfaceBasedAfterCommitJobs() { $coroutine = new CoroutineQueue(); + $coroutine->setConnectionName('coroutine'); $container = $this->getContainer(); - $transactionManager = m::mock(TransactionManager::class); + $transactionManager = m::mock(DatabaseTransactionsManager::class); $transactionManager->shouldReceive('addCallback')->once()->andReturn(null); - $container->set(TransactionManager::class, $transactionManager); + $container->set(DatabaseTransactionsManager::class, $transactionManager); $coroutine->setContainer($container); run(fn () => $coroutine->push(new CoroutineQueueAfterCommitInterfaceJob())); diff --git a/tests/Queue/QueueDatabaseQueueIntegrationTest.php b/tests/Queue/QueueDatabaseQueueIntegrationTest.php index 98b613bc0..a81b6e667 100644 --- a/tests/Queue/QueueDatabaseQueueIntegrationTest.php +++ b/tests/Queue/QueueDatabaseQueueIntegrationTest.php @@ -4,14 +4,14 @@ namespace Hypervel\Tests\Queue; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Stringable\Str; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Queue\DatabaseQueue; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; use Hypervel\Support\Carbon; +use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; use Mockery as m; use Psr\EventDispatcher\EventDispatcherInterface; @@ -48,8 +48,6 @@ protected function tearDown(): void { parent::tearDown(); - m::close(); - Uuid::setFactory(new UuidFactory()); } diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index b3a83b59b..80d1f2d20 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -4,13 +4,13 @@ namespace Hypervel\Tests\Queue; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Builder; use Hyperf\Di\Container; -use Hyperf\Stringable\Str; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; use Hypervel\Queue\DatabaseQueue; use Hypervel\Queue\Queue; +use Hypervel\Support\Str; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -29,8 +29,6 @@ class QueueDatabaseQueueUnitTest extends TestCase { protected function tearDown(): void { - m::close(); - Uuid::setFactory(new UuidFactory()); } @@ -56,6 +54,8 @@ public function testPushProperlyPushesJobOntoDatabase($uuid, $job, $displayNameS $this->assertEquals(0, $array['attempts']); $this->assertNull($array['reserved_at']); $this->assertIsInt($array['available_at']); + + return 1; }); $queue->push($job, ['data']); @@ -98,6 +98,8 @@ public function testDelayedPushProperlyPushesJobOntoDatabase() $this->assertEquals(0, $array['attempts']); $this->assertNull($array['reserved_at']); $this->assertIsInt($array['available_at']); + + return 1; }); $queue->later(10, 'foo', ['data']); @@ -167,6 +169,8 @@ public function testBulkBatchPushesOntoDatabase() 'available_at' => 1732502704, 'created_at' => 1732502704, ]], $records); + + return true; }); $queue->bulk(['foo', 'bar'], ['data'], 'queue'); diff --git a/tests/Queue/QueueDeferQueueTest.php b/tests/Queue/QueueDeferQueueTest.php index 671e2f7d2..33042b671 100644 --- a/tests/Queue/QueueDeferQueueTest.php +++ b/tests/Queue/QueueDeferQueueTest.php @@ -7,9 +7,9 @@ use Exception; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Database\TransactionManager; -use Hypervel\Queue\Contracts\QueueableEntity; -use Hypervel\Queue\Contracts\ShouldQueueAfterCommit; +use Hypervel\Contracts\Queue\QueueableEntity; +use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\DeferQueue; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Jobs\SyncJob; @@ -69,10 +69,11 @@ public function testFailedJobGetsHandledWhenAnExceptionIsThrown() public function testItAddsATransactionCallbackForAfterCommitJobs() { $defer = new DeferQueue(); + $defer->setConnectionName('defer'); $container = $this->getContainer(); - $transactionManager = m::mock(TransactionManager::class); + $transactionManager = m::mock(DatabaseTransactionsManager::class); $transactionManager->shouldReceive('addCallback')->once()->andReturn(null); - $container->set(TransactionManager::class, $transactionManager); + $container->set(DatabaseTransactionsManager::class, $transactionManager); $defer->setContainer($container); run(fn () => $defer->push(new DeferQueueAfterCommitJob())); @@ -81,10 +82,11 @@ public function testItAddsATransactionCallbackForAfterCommitJobs() public function testItAddsATransactionCallbackForInterfaceBasedAfterCommitJobs() { $defer = new DeferQueue(); + $defer->setConnectionName('defer'); $container = $this->getContainer(); - $transactionManager = m::mock(TransactionManager::class); + $transactionManager = m::mock(DatabaseTransactionsManager::class); $transactionManager->shouldReceive('addCallback')->once()->andReturn(null); - $container->set(TransactionManager::class, $transactionManager); + $container->set(DatabaseTransactionsManager::class, $transactionManager); $defer->setContainer($container); run(fn () => $defer->push(new DeferQueueAfterCommitInterfaceJob())); diff --git a/tests/Queue/QueueDelayTest.php b/tests/Queue/QueueDelayTest.php index 885a0eaf5..5c166f209 100644 --- a/tests/Queue/QueueDelayTest.php +++ b/tests/Queue/QueueDelayTest.php @@ -7,10 +7,10 @@ use Hyperf\Context\ApplicationContext; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Bus\Contracts\Dispatcher; use Hypervel\Bus\PendingDispatch; use Hypervel\Bus\Queueable; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Queue\ShouldQueue; use Mockery; use PHPUnit\Framework\TestCase; diff --git a/tests/Queue/QueueListenerTest.php b/tests/Queue/QueueListenerTest.php index d1b1bfd5d..61adeae49 100644 --- a/tests/Queue/QueueListenerTest.php +++ b/tests/Queue/QueueListenerTest.php @@ -16,11 +16,6 @@ */ class QueueListenerTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testRunProcessCallsProcess() { $process = m::mock(Process::class)->makePartial(); diff --git a/tests/Queue/QueueManagerTest.php b/tests/Queue/QueueManagerTest.php index a4a9430d5..52332f413 100644 --- a/tests/Queue/QueueManagerTest.php +++ b/tests/Queue/QueueManagerTest.php @@ -9,11 +9,11 @@ use Hyperf\Contract\ConfigInterface; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Encryption\Contracts\Encrypter; +use Hypervel\Contracts\Encryption\Encrypter; +use Hypervel\Contracts\Queue\Queue; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\ObjectPool\PoolManager; use Hypervel\Queue\Connectors\ConnectorInterface; -use Hypervel\Queue\Contracts\Queue; use Hypervel\Queue\QueueManager; use Hypervel\Queue\QueuePoolProxy; use Mockery as m; @@ -25,11 +25,6 @@ */ class QueueManagerTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testDefaultConnectionCanBeResolved() { $container = $this->getContainer(); diff --git a/tests/Queue/QueueRedisJobTest.php b/tests/Queue/QueueRedisJobTest.php index d41b8511a..a479aa4f5 100644 --- a/tests/Queue/QueueRedisJobTest.php +++ b/tests/Queue/QueueRedisJobTest.php @@ -17,11 +17,6 @@ */ class QueueRedisJobTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testFireProperlyCallsTheJobHandler() { $job = $this->getJob(); diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index 73ca69433..19d48a94d 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -7,11 +7,11 @@ use Hyperf\Di\Container; use Hyperf\Redis\RedisFactory; use Hyperf\Redis\RedisProxy; -use Hyperf\Stringable\Str; use Hypervel\Queue\LuaScripts; use Hypervel\Queue\Queue; use Hypervel\Queue\RedisQueue; use Hypervel\Support\Carbon; +use Hypervel\Support\Str; use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; @@ -28,8 +28,6 @@ class QueueRedisQueueTest extends TestCase { protected function tearDown(): void { - m::close(); - Uuid::setFactory(new UuidFactory()); } diff --git a/tests/Queue/QueueSizeTest.php b/tests/Queue/QueueSizeTest.php index a06e9c9fc..ffe7a8a21 100644 --- a/tests/Queue/QueueSizeTest.php +++ b/tests/Queue/QueueSizeTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Queue; use Hypervel\Bus\Queueable; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Support\Facades\Queue; use Hypervel\Testbench\TestCase; diff --git a/tests/Queue/QueueSqsJobTest.php b/tests/Queue/QueueSqsJobTest.php index 25ee13bb9..08a31cce9 100644 --- a/tests/Queue/QueueSqsJobTest.php +++ b/tests/Queue/QueueSqsJobTest.php @@ -87,11 +87,6 @@ protected function setUp(): void ]; } - protected function tearDown(): void - { - m::close(); - } - public function testFireProperlyCallsTheJobHandler() { $job = $this->getJob(); diff --git a/tests/Queue/QueueSqsQueueTest.php b/tests/Queue/QueueSqsQueueTest.php index 84865646d..f0bdada75 100644 --- a/tests/Queue/QueueSqsQueueTest.php +++ b/tests/Queue/QueueSqsQueueTest.php @@ -53,11 +53,6 @@ class QueueSqsQueueTest extends TestCase protected $mockedQueueAttributesResponseModel; - protected function tearDown(): void - { - m::close(); - } - protected function setUp(): void { // Use Mockery to mock the SqsClient diff --git a/tests/Queue/QueueSyncQueueTest.php b/tests/Queue/QueueSyncQueueTest.php index cf0139865..b581ccda7 100644 --- a/tests/Queue/QueueSyncQueueTest.php +++ b/tests/Queue/QueueSyncQueueTest.php @@ -7,11 +7,11 @@ use Exception; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Bus\Contracts\Dispatcher; -use Hypervel\Database\TransactionManager; -use Hypervel\Queue\Contracts\QueueableEntity; -use Hypervel\Queue\Contracts\ShouldQueue; -use Hypervel\Queue\Contracts\ShouldQueueAfterCommit; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Queue\QueueableEntity; +use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Jobs\SyncJob; use Hypervel\Queue\SyncQueue; @@ -90,10 +90,11 @@ public function testCreatesPayloadObject() public function testItAddsATransactionCallbackForAfterCommitJobs() { $sync = new SyncQueue(); + $sync->setConnectionName('sync'); $container = $this->getContainer(); - $transactionManager = m::mock(TransactionManager::class); + $transactionManager = m::mock(DatabaseTransactionsManager::class); $transactionManager->shouldReceive('addCallback')->once()->andReturn(null); - $container->set(TransactionManager::class, $transactionManager); + $container->set(DatabaseTransactionsManager::class, $transactionManager); $sync->setContainer($container); $sync->push(new SyncQueueAfterCommitJob()); @@ -102,10 +103,11 @@ public function testItAddsATransactionCallbackForAfterCommitJobs() public function testItAddsATransactionCallbackForInterfaceBasedAfterCommitJobs() { $sync = new SyncQueue(); + $sync->setConnectionName('sync'); $container = $this->getContainer(); - $transactionManager = m::mock(TransactionManager::class); + $transactionManager = m::mock(DatabaseTransactionsManager::class); $transactionManager->shouldReceive('addCallback')->once()->andReturn(null); - $container->set(TransactionManager::class, $transactionManager); + $container->set(DatabaseTransactionsManager::class, $transactionManager); $sync->setContainer($container); $sync->push(new SyncQueueAfterCommitInterfaceJob()); diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 6fd85c062..b0ffbc47a 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -10,11 +10,12 @@ use Hyperf\Context\ApplicationContext; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Event\Dispatcher as EventDispatcher; +use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\Job as QueueJobContract; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; -use Hypervel\Queue\Contracts\Job; -use Hypervel\Queue\Contracts\Job as QueueJobContract; -use Hypervel\Queue\Contracts\Queue; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobPopped; use Hypervel\Queue\Events\JobPopping; @@ -28,7 +29,6 @@ use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; -use Psr\EventDispatcher\EventDispatcherInterface; use RuntimeException; use Throwable; @@ -40,7 +40,7 @@ class QueueWorkerTest extends TestCase { use RunTestsInCoroutine; - protected EventDispatcherInterface $events; + protected EventDispatcher $events; protected ExceptionHandlerContract $exceptionHandler; @@ -48,11 +48,11 @@ class QueueWorkerTest extends TestCase protected function setUp(): void { - $this->events = m::spy(EventDispatcherInterface::class); + $this->events = m::spy(EventDispatcher::class); $this->exceptionHandler = m::spy(ExceptionHandlerContract::class); $this->container = new Container( new DefinitionSource([ - EventDispatcherInterface::class => fn () => $this->events, + EventDispatcher::class => fn () => $this->events, ExceptionHandlerContract::class => fn () => $this->exceptionHandler, ]) ); diff --git a/tests/Queue/RateLimitedTest.php b/tests/Queue/RateLimitedTest.php index 44b0e50c7..f2db9d27c 100644 --- a/tests/Queue/RateLimitedTest.php +++ b/tests/Queue/RateLimitedTest.php @@ -35,12 +35,6 @@ enum RateLimitedTestUnitEnum */ class RateLimitedTest extends TestCase { - protected function tearDown(): void - { - parent::tearDown(); - Mockery::close(); - } - public function testConstructorAcceptsString(): void { $this->mockRateLimiter(); diff --git a/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php b/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php index ad3f3fdd9..941e3f637 100644 --- a/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php +++ b/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration { diff --git a/tests/Queue/migrations/2024_11_20_000000_create_jobs_table.php b/tests/Queue/migrations/2024_11_20_000000_create_jobs_table.php index ad4aca836..712515250 100644 --- a/tests/Queue/migrations/2024_11_20_000000_create_jobs_table.php +++ b/tests/Queue/migrations/2024_11_20_000000_create_jobs_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration { diff --git a/tests/Redis/RedisTest.php b/tests/Redis/RedisTest.php index 62320947f..77ff9568a 100644 --- a/tests/Redis/RedisTest.php +++ b/tests/Redis/RedisTest.php @@ -248,7 +248,7 @@ public function testConnectionAcceptsStringBackedEnum(): void ->once() ->andReturn($mockRedisProxy); - $mockContainer = Mockery::mock(\Hypervel\Container\Contracts\Container::class); + $mockContainer = Mockery::mock(\Hypervel\Contracts\Container\Container::class); $mockContainer->shouldReceive('get') ->with(RedisFactory::class) ->andReturn($mockRedisFactory); @@ -272,7 +272,7 @@ public function testConnectionAcceptsUnitEnum(): void ->once() ->andReturn($mockRedisProxy); - $mockContainer = Mockery::mock(\Hypervel\Container\Contracts\Container::class); + $mockContainer = Mockery::mock(\Hypervel\Contracts\Container\Container::class); $mockContainer->shouldReceive('get') ->with(RedisFactory::class) ->andReturn($mockRedisFactory); @@ -290,7 +290,7 @@ public function testConnectionWithIntBackedEnumThrowsTypeError(): void { $mockRedisFactory = Mockery::mock(RedisFactory::class); - $mockContainer = Mockery::mock(\Hypervel\Container\Contracts\Container::class); + $mockContainer = Mockery::mock(\Hypervel\Contracts\Container\Container::class); $mockContainer->shouldReceive('get') ->with(RedisFactory::class) ->andReturn($mockRedisFactory); diff --git a/tests/Router/FunctionsTest.php b/tests/Router/FunctionsTest.php index 49844d6b4..19e128811 100644 --- a/tests/Router/FunctionsTest.php +++ b/tests/Router/FunctionsTest.php @@ -6,7 +6,7 @@ use Hyperf\Context\ApplicationContext; use Hyperf\Contract\ContainerInterface; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; use Hypervel\Tests\TestCase; use Mockery; use Mockery\MockInterface; diff --git a/tests/Router/Stub/UrlRoutableStub.php b/tests/Router/Stub/UrlRoutableStub.php index f7396de4b..1079a9afa 100644 --- a/tests/Router/Stub/UrlRoutableStub.php +++ b/tests/Router/Stub/UrlRoutableStub.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Router\Stub; -use Hyperf\Database\Model\Model; -use Hypervel\Router\Contracts\UrlRoutable; +use Hypervel\Contracts\Router\UrlRoutable; +use Hypervel\Database\Eloquent\Model; class UrlRoutableStub implements UrlRoutable { diff --git a/tests/Sanctum/ActingAsTest.php b/tests/Sanctum/ActingAsTest.php index 7496323c2..a91d3d249 100644 --- a/tests/Sanctum/ActingAsTest.php +++ b/tests/Sanctum/ActingAsTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\Sanctum; use Hyperf\Contract\ConfigInterface; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; use Hypervel\Context\Context; +use Hypervel\Contracts\Auth\Factory as AuthFactoryContract; use Hypervel\Sanctum\Sanctum; use Hypervel\Sanctum\SanctumServiceProvider; use Hypervel\Testbench\TestCase; diff --git a/tests/Sanctum/AuthenticateRequestsTest.php b/tests/Sanctum/AuthenticateRequestsTest.php index af83035b0..160294d0e 100644 --- a/tests/Sanctum/AuthenticateRequestsTest.php +++ b/tests/Sanctum/AuthenticateRequestsTest.php @@ -4,14 +4,14 @@ namespace Hypervel\Tests\Sanctum; -use Hyperf\Contract\ConfigInterface; use Hypervel\Context\Context; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Router\Router; use Hypervel\Sanctum\PersonalAccessToken; use Hypervel\Sanctum\Sanctum; use Hypervel\Sanctum\SanctumServiceProvider; -use Hypervel\Support\Facades\Route; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Sanctum\Stub\TestUser; @@ -30,31 +30,37 @@ protected function setUp(): void { parent::setUp(); - $this->app->register(SanctumServiceProvider::class); - - // Configure test environment - $this->app->get(ConfigInterface::class) - ->set([ - 'app.key' => 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF', - 'auth.guards.sanctum' => [ - 'driver' => 'sanctum', - 'provider' => 'users', - ], - 'auth.guards.web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - 'auth.providers.users.model' => TestUser::class, - 'auth.providers.users.driver' => 'eloquent', - 'database.default' => 'testing', - 'sanctum.stateful' => ['localhost', '127.0.0.1'], - 'sanctum.guard' => ['web'], - ]); - - $this->defineRoutes(); $this->createUsersTable(); } + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + SanctumServiceProvider::class, + ]; + } + + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $app->get('config')->set([ + 'app.key' => 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF', + 'auth.guards.sanctum' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], + 'auth.guards.web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + 'auth.providers.users.model' => TestUser::class, + 'auth.providers.users.driver' => 'eloquent', + 'sanctum.stateful' => ['localhost', '127.0.0.1'], + 'sanctum.guard' => ['web'], + ]); + } + protected function tearDown(): void { parent::tearDown(); @@ -89,9 +95,9 @@ protected function createUsersTable(): void }); } - protected function defineRoutes(): void + protected function defineRoutes(Router $router): void { - Route::get('/sanctum/api/user', function () { + $router->get('/sanctum/api/user', function () { $user = auth('sanctum')->user(); if (! $user) { @@ -101,7 +107,7 @@ protected function defineRoutes(): void return response()->json(['email' => $user->email]); }); - Route::get('/sanctum/web/user', function () { + $router->get('/sanctum/web/user', function () { $user = auth('sanctum')->user(); if (! $user) { diff --git a/tests/Sanctum/CheckAbilitiesTest.php b/tests/Sanctum/CheckAbilitiesTest.php index ac85abfb2..0f7869112 100644 --- a/tests/Sanctum/CheckAbilitiesTest.php +++ b/tests/Sanctum/CheckAbilitiesTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Sanctum; -use Hypervel\Auth\Contracts\Factory as AuthFactory; -use Hypervel\Auth\Contracts\Guard; +use Hypervel\Contracts\Auth\Factory as AuthFactory; +use Hypervel\Contracts\Auth\Guard; use Hypervel\Sanctum\Http\Middleware\CheckAbilities; use Mockery; use PHPUnit\Framework\TestCase; @@ -18,17 +18,10 @@ */ class CheckAbilitiesTest extends TestCase { - protected function tearDown(): void - { - parent::tearDown(); - - Mockery::close(); - } - public function testRequestIsPassedAlongIfAbilitiesArePresentOnToken(): void { // Create a user object with the required methods - $user = new class implements \Hypervel\Auth\Contracts\Authenticatable { + $user = new class implements \Hypervel\Contracts\Auth\Authenticatable { private $token; public function __construct() @@ -84,7 +77,7 @@ public function testExceptionIsThrownIfTokenDoesntHaveAbility(): void { $this->expectException(\Hypervel\Sanctum\Exceptions\MissingAbilityException::class); - $user = new class implements \Hypervel\Auth\Contracts\Authenticatable { + $user = new class implements \Hypervel\Contracts\Auth\Authenticatable { private $token; public function __construct() @@ -156,7 +149,7 @@ public function testExceptionIsThrownIfNoToken(): void { $this->expectException(\Hypervel\Auth\AuthenticationException::class); - $user = new class implements \Hypervel\Auth\Contracts\Authenticatable { + $user = new class implements \Hypervel\Contracts\Auth\Authenticatable { public function currentAccessToken() { return null; diff --git a/tests/Sanctum/CheckForAnyAbilityTest.php b/tests/Sanctum/CheckForAnyAbilityTest.php index fb66374ac..960e4164e 100644 --- a/tests/Sanctum/CheckForAnyAbilityTest.php +++ b/tests/Sanctum/CheckForAnyAbilityTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Sanctum; -use Hypervel\Auth\Contracts\Factory as AuthFactory; -use Hypervel\Auth\Contracts\Guard; +use Hypervel\Contracts\Auth\Factory as AuthFactory; +use Hypervel\Contracts\Auth\Guard; use Hypervel\Sanctum\Http\Middleware\CheckForAnyAbility; use Mockery; use PHPUnit\Framework\TestCase; @@ -18,20 +18,13 @@ */ class CheckForAnyAbilityTest extends TestCase { - protected function tearDown(): void - { - parent::tearDown(); - - Mockery::close(); - } - /** * Test request is passed along if any abilities are present on token. */ public function testRequestIsPassedAlongIfAbilitiesArePresentOnToken(): void { // Create a user object with the required methods - $user = new class implements \Hypervel\Auth\Contracts\Authenticatable { + $user = new class implements \Hypervel\Contracts\Auth\Authenticatable { private $token; public function __construct() @@ -88,7 +81,7 @@ public function testExceptionIsThrownIfTokenDoesntHaveAbility(): void { $this->expectException(\Hypervel\Sanctum\Exceptions\MissingAbilityException::class); - $user = new class implements \Hypervel\Auth\Contracts\Authenticatable { + $user = new class implements \Hypervel\Contracts\Auth\Authenticatable { private $token; public function __construct() @@ -160,7 +153,7 @@ public function testExceptionIsThrownIfNoToken(): void { $this->expectException(\Hypervel\Auth\AuthenticationException::class); - $user = new class implements \Hypervel\Auth\Contracts\Authenticatable { + $user = new class implements \Hypervel\Contracts\Auth\Authenticatable { public function currentAccessToken() { return null; diff --git a/tests/Sanctum/FrontendRequestsAreStatefulTest.php b/tests/Sanctum/FrontendRequestsAreStatefulTest.php index 9cd1c295e..3f02ba233 100644 --- a/tests/Sanctum/FrontendRequestsAreStatefulTest.php +++ b/tests/Sanctum/FrontendRequestsAreStatefulTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Sanctum; use Hyperf\Contract\ConfigInterface; -use Hyperf\Testing\ModelFactory; use Hypervel\Auth\Middleware\Authenticate; use Hypervel\Foundation\Http\Middleware\VerifyCsrfToken; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; @@ -17,7 +16,7 @@ use Hypervel\Session\Middleware\StartSession; use Hypervel\Support\Collection; use Hypervel\Testbench\TestCase; -use Workbench\App\Models\User; +use Hypervel\Tests\Sanctum\Stub\User; /** * @internal @@ -30,6 +29,17 @@ class FrontendRequestsAreStatefulTest extends TestCase protected bool $migrateRefresh = true; + protected function migrateFreshUsing(): array + { + return [ + '--realpath' => true, + '--path' => [ + __DIR__ . '/../../src/sanctum/database/migrations', + __DIR__ . '/migrations', + ], + ]; + } + public function setUp(): void { parent::setUp(); @@ -144,9 +154,6 @@ public function testMiddlewareKeepsSessionLoggedInWhenSanctumRequestChangesPassw protected function createUser(array $attributes = []): User { - return $this->app - ->get(ModelFactory::class) - ->factory(User::class) - ->create($attributes); + return User::factory()->create($attributes); } } diff --git a/tests/Sanctum/GuardTest.php b/tests/Sanctum/GuardTest.php index 780412fcf..536cc2b97 100644 --- a/tests/Sanctum/GuardTest.php +++ b/tests/Sanctum/GuardTest.php @@ -48,7 +48,6 @@ protected function setUp(): void ], 'auth.providers.users.model' => TestUser::class, 'auth.providers.users.driver' => 'eloquent', - 'database.default' => 'testing', 'sanctum.guard' => ['web'], ]); @@ -63,7 +62,6 @@ protected function tearDown(): void Context::destroy('__sanctum.acting_as_user'); Context::destroy('__sanctum.acting_as_guard'); - Mockery::close(); Sanctum::$accessTokenRetrievalCallback = null; Sanctum::$accessTokenAuthenticationCallback = null; } diff --git a/tests/Sanctum/SimpleGuardTest.php b/tests/Sanctum/SimpleGuardTest.php index cd96026b3..85eff5df4 100644 --- a/tests/Sanctum/SimpleGuardTest.php +++ b/tests/Sanctum/SimpleGuardTest.php @@ -37,7 +37,6 @@ protected function setUp(): void ], 'auth.providers.users.model' => TestUser::class, 'auth.providers.users.driver' => 'eloquent', - 'database.default' => 'testing', ]); // Create users table diff --git a/tests/Sanctum/Stub/EloquentUserProvider.php b/tests/Sanctum/Stub/EloquentUserProvider.php index 16beac761..207740363 100644 --- a/tests/Sanctum/Stub/EloquentUserProvider.php +++ b/tests/Sanctum/Stub/EloquentUserProvider.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Sanctum\Stub; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\UserProvider; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\UserProvider; /** * Simple user provider for testing. diff --git a/tests/Sanctum/Stub/TestUser.php b/tests/Sanctum/Stub/TestUser.php index b2fc3c30c..155d93ede 100644 --- a/tests/Sanctum/Stub/TestUser.php +++ b/tests/Sanctum/Stub/TestUser.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Sanctum\Stub; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Database\Eloquent\Model; use Hypervel\Sanctum\HasApiTokens; diff --git a/tests/Sanctum/Stub/User.php b/tests/Sanctum/Stub/User.php index e61708e2d..bcc87ae8b 100644 --- a/tests/Sanctum/Stub/User.php +++ b/tests/Sanctum/Stub/User.php @@ -4,22 +4,27 @@ namespace Hypervel\Tests\Sanctum\Stub; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Database\Eloquent\Factories\Factory; +use Hypervel\Database\Eloquent\Factories\HasFactory; +use Hypervel\Database\Eloquent\Model; use Hypervel\Sanctum\HasApiTokens; -class User implements Authenticatable +class User extends Model implements Authenticatable { use HasApiTokens; + use HasFactory; - public int $id = 1; + protected ?string $table = 'sanctum_test_users'; - public bool $wasRecentlyCreated = false; + protected array $fillable = ['name', 'email', 'password']; - public string $email = 'test@example.com'; + protected array $hidden = ['password']; - public string $password = ''; - - public string $name = 'Test User'; + protected static function newFactory(): UserFactory + { + return UserFactory::new(); + } public function getAuthIdentifierName(): string { @@ -33,6 +38,20 @@ public function getAuthIdentifier(): mixed public function getAuthPassword(): string { - return $this->password ?: 'password'; + return $this->password; + } +} + +class UserFactory extends Factory +{ + protected ?string $model = User::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + 'email' => $this->faker->unique()->safeEmail(), + 'password' => bcrypt('password'), + ]; } } diff --git a/tests/Sanctum/migrations/2023_08_03_000000_create_personal_access_tokens_table.php b/tests/Sanctum/migrations/2023_08_03_000000_create_personal_access_tokens_table.php index 1e088be9f..155fe42b2 100644 --- a/tests/Sanctum/migrations/2023_08_03_000000_create_personal_access_tokens_table.php +++ b/tests/Sanctum/migrations/2023_08_03_000000_create_personal_access_tokens_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration { diff --git a/tests/Sanctum/migrations/2024_01_01_000001_create_sanctum_test_users_table.php b/tests/Sanctum/migrations/2024_01_01_000001_create_sanctum_test_users_table.php new file mode 100644 index 000000000..93c4f7ce1 --- /dev/null +++ b/tests/Sanctum/migrations/2024_01_01_000001_create_sanctum_test_users_table.php @@ -0,0 +1,20 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->timestamps(); + }); + } +}; diff --git a/tests/Sentry/Features/ConsoleSchedulingFeatureTest.php b/tests/Sentry/Features/ConsoleSchedulingFeatureTest.php index 700ba6b32..879088352 100644 --- a/tests/Sentry/Features/ConsoleSchedulingFeatureTest.php +++ b/tests/Sentry/Features/ConsoleSchedulingFeatureTest.php @@ -8,8 +8,8 @@ use Hypervel\Bus\Queueable; use Hypervel\Console\Scheduling\Event; use Hypervel\Console\Scheduling\Schedule; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; -use Hypervel\Queue\Contracts\ShouldQueue; use Hypervel\Sentry\Features\ConsoleSchedulingFeature; use Hypervel\Tests\Sentry\SentryTestCase; use RuntimeException; diff --git a/tests/Sentry/Features/DbQueryFeatureTest.php b/tests/Sentry/Features/DbQueryFeatureTest.php index 846faac70..01e1873d6 100644 --- a/tests/Sentry/Features/DbQueryFeatureTest.php +++ b/tests/Sentry/Features/DbQueryFeatureTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Sentry\Features; -use Hyperf\Database\Connection; -use Hyperf\Database\Events\QueryExecuted; -use Hyperf\Database\Events\TransactionBeginning; -use Hyperf\Database\Events\TransactionCommitted; -use Hyperf\Database\Events\TransactionRolledBack; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; +use Hypervel\Database\Connection; +use Hypervel\Database\Events\QueryExecuted; +use Hypervel\Database\Events\TransactionBeginning; +use Hypervel\Database\Events\TransactionCommitted; +use Hypervel\Database\Events\TransactionRolledBack; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Sentry\Features\DbQueryFeature; use Hypervel\Tests\Sentry\SentryTestCase; @@ -33,6 +33,14 @@ class DbQueryFeatureTest extends SentryTestCase 'sentry.breadcrumbs.sql_transaction' => true, ]; + /** + * Create a test database connection for event testing. + */ + protected function createTestConnection(): Connection + { + return new Connection(fn () => null, '', '', ['name' => 'sqlite']); + } + public function testFeatureIsApplicableWhenSqlQueriesBreadcrumbIsEnabled(): void { $this->resetApplicationWithConfig([ @@ -79,7 +87,7 @@ public function testQueryExecutedEventCreatesCorrectBreadcrumb(): void 'SELECT * FROM users WHERE id = ?', [123], 50.0, - new Connection('sqlite', config: ['name' => 'sqlite']) + $this->createTestConnection() ); $dispatcher->dispatch($event); @@ -113,7 +121,7 @@ public function testQueryExecutedEventWithoutBindingsWhenDisabled(): void 'SELECT * FROM users WHERE id = ?', [123], 50.0, - new Connection('sqlite', config: ['name' => 'sqlite']) + $this->createTestConnection() ); $dispatcher->dispatch($event); @@ -135,7 +143,7 @@ public function testTransactionBeginningEventCreatesCorrectBreadcrumb(): void { $dispatcher = $this->app->get(Dispatcher::class); - $event = new TransactionBeginning(new Connection('sqlite', config: ['name' => 'sqlite'])); + $event = new TransactionBeginning($this->createTestConnection()); $dispatcher->dispatch($event); @@ -156,7 +164,7 @@ public function testTransactionCommittedEventCreatesCorrectBreadcrumb(): void { $dispatcher = $this->app->get(Dispatcher::class); - $event = new TransactionCommitted(new Connection('sqlite', config: ['name' => 'sqlite'])); + $event = new TransactionCommitted($this->createTestConnection()); $dispatcher->dispatch($event); @@ -177,7 +185,7 @@ public function testTransactionRolledBackEventCreatesCorrectBreadcrumb(): void { $dispatcher = $this->app->get(Dispatcher::class); - $event = new TransactionRolledBack(new Connection('sqlite', config: ['name' => 'sqlite'])); + $event = new TransactionRolledBack($this->createTestConnection()); $dispatcher->dispatch($event); @@ -206,7 +214,7 @@ public function testQueryExecutedEventIsIgnoredWhenFeatureDisabled(): void 'SELECT * FROM users WHERE id = ?', [123], 50.0, - new Connection('sqlite', config: ['name' => 'sqlite']) + $this->createTestConnection() ); $dispatcher->dispatch($event); @@ -226,7 +234,7 @@ public function testTransactionEventIsIgnoredWhenFeatureDisabled(): void $dispatcher = $this->app->get(Dispatcher::class); - $event = new TransactionBeginning(new Connection('sqlite', config: ['name' => 'sqlite'])); + $event = new TransactionBeginning($this->createTestConnection()); $dispatcher->dispatch($event); diff --git a/tests/Sentry/Features/LogFeatureTest.php b/tests/Sentry/Features/LogFeatureTest.php index 91e69eaa8..57e0674b6 100644 --- a/tests/Sentry/Features/LogFeatureTest.php +++ b/tests/Sentry/Features/LogFeatureTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Sentry\Features; use Hyperf\Contract\ConfigInterface; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Sentry\Features\LogFeature; use Hypervel\Support\Facades\Log; @@ -26,7 +27,7 @@ class LogFeatureTest extends SentryTestCase ], ]; - protected function defineEnvironment($app): void + protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); diff --git a/tests/Sentry/Features/NotificationsFeatureTest.php b/tests/Sentry/Features/NotificationsFeatureTest.php index e40507b0c..bbc6caad4 100644 --- a/tests/Sentry/Features/NotificationsFeatureTest.php +++ b/tests/Sentry/Features/NotificationsFeatureTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Sentry\Features; use Hyperf\ViewEngine\Contract\FactoryInterface as ViewFactory; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Notifications\Messages\MailMessage; use Hypervel\Sentry\Features\NotificationsFeature; diff --git a/tests/Sentry/Features/QueueFeatureTest.php b/tests/Sentry/Features/QueueFeatureTest.php index 3fb941338..58db8ed1a 100644 --- a/tests/Sentry/Features/QueueFeatureTest.php +++ b/tests/Sentry/Features/QueueFeatureTest.php @@ -6,8 +6,8 @@ use Exception; use Hyperf\Contract\ConfigInterface; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; -use Hypervel\Queue\Contracts\ShouldQueue; use Hypervel\Sentry\Features\QueueFeature; use Hypervel\Tests\Sentry\SentryTestCase; use Sentry\Breadcrumb; diff --git a/tests/Sentry/Features/RedisFeatureTest.php b/tests/Sentry/Features/RedisFeatureTest.php index 5f1d16b31..aa35dc524 100644 --- a/tests/Sentry/Features/RedisFeatureTest.php +++ b/tests/Sentry/Features/RedisFeatureTest.php @@ -10,7 +10,7 @@ use Hyperf\Redis\Pool\PoolFactory; use Hyperf\Redis\Pool\RedisPool; use Hyperf\Redis\RedisConnection; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Sentry\Features\RedisFeature; use Hypervel\Session\SessionManager; diff --git a/tests/Sentry/SentryTestCase.php b/tests/Sentry/SentryTestCase.php index 2be11b924..e80f848a9 100644 --- a/tests/Sentry/SentryTestCase.php +++ b/tests/Sentry/SentryTestCase.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Sentry; use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Sentry\SentryServiceProvider; use Hypervel\Testbench\ConfigProviderRegister; use ReflectionException; diff --git a/tests/Session/EncryptedSessionStoreTest.php b/tests/Session/EncryptedSessionStoreTest.php index 8511e58c8..38cf68dd8 100644 --- a/tests/Session/EncryptedSessionStoreTest.php +++ b/tests/Session/EncryptedSessionStoreTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Session; -use Hypervel\Encryption\Contracts\Encrypter; +use Hypervel\Contracts\Encryption\Encrypter; use Hypervel\Session\EncryptedStore; use Hypervel\Tests\TestCase; use Mockery as m; diff --git a/tests/Session/SessionStoreTest.php b/tests/Session/SessionStoreTest.php index d07872a3d..bc53b200e 100644 --- a/tests/Session/SessionStoreTest.php +++ b/tests/Session/SessionStoreTest.php @@ -5,10 +5,10 @@ namespace Hypervel\Tests\Session; use Hyperf\Context\Context; -use Hyperf\Stringable\Str; use Hyperf\Support\MessageBag; use Hyperf\ViewEngine\ViewErrorBag; use Hypervel\Session\Store; +use Hypervel\Support\Str; use Hypervel\Tests\TestCase; use Mockery as m; use SessionHandlerInterface; diff --git a/tests/Socialite/GoogleProviderTest.php b/tests/Socialite/GoogleProviderTest.php index 6417b197e..e58ba4502 100644 --- a/tests/Socialite/GoogleProviderTest.php +++ b/tests/Socialite/GoogleProviderTest.php @@ -6,8 +6,8 @@ use GuzzleHttp\Client; use GuzzleHttp\RequestOptions; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Hypervel\Socialite\Two\User; use Hypervel\Tests\Socialite\Fixtures\GoogleTestProviderStub; use Hypervel\Tests\TestCase; diff --git a/tests/Socialite/LinkedInOpenIdProviderTest.php b/tests/Socialite/LinkedInOpenIdProviderTest.php index f5eb8ff51..8a6d7b5d0 100644 --- a/tests/Socialite/LinkedInOpenIdProviderTest.php +++ b/tests/Socialite/LinkedInOpenIdProviderTest.php @@ -7,8 +7,8 @@ use GuzzleHttp\Client; use GuzzleHttp\RequestOptions; use Hypervel\Context\Context; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Hypervel\Socialite\Contracts\User as UserContract; use Hypervel\Socialite\Two\LinkedInOpenIdProvider; use Hypervel\Socialite\Two\User; diff --git a/tests/Socialite/LinkedInProviderTest.php b/tests/Socialite/LinkedInProviderTest.php index 7e18dd37b..7430cf9f9 100644 --- a/tests/Socialite/LinkedInProviderTest.php +++ b/tests/Socialite/LinkedInProviderTest.php @@ -7,8 +7,8 @@ use GuzzleHttp\Client; use GuzzleHttp\RequestOptions; use Hypervel\Context\Context; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Hypervel\Socialite\Two\LinkedInProvider; use Hypervel\Socialite\Two\User; use Hypervel\Tests\TestCase; diff --git a/tests/Socialite/OAuthOneTest.php b/tests/Socialite/OAuthOneTest.php index 5b86e180c..5cc9b1153 100644 --- a/tests/Socialite/OAuthOneTest.php +++ b/tests/Socialite/OAuthOneTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Socialite; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; +use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Socialite\One\MissingTemporaryCredentialsException; use Hypervel\Socialite\One\MissingVerifierException; use Hypervel\Socialite\One\User as SocialiteUser; @@ -25,13 +25,6 @@ */ class OAuthOneTest extends TestCase { - protected function tearDown(): void - { - parent::tearDown(); - - m::close(); - } - public function testRedirectGeneratesTheProperRedirectResponse() { $server = m::mock(Twitter::class); diff --git a/tests/Socialite/OAuthTwoTest.php b/tests/Socialite/OAuthTwoTest.php index 3afa437cb..ddc0e269c 100644 --- a/tests/Socialite/OAuthTwoTest.php +++ b/tests/Socialite/OAuthTwoTest.php @@ -6,9 +6,9 @@ use GuzzleHttp\Client; use Hypervel\Context\Context; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; +use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Socialite\Two\Exceptions\InvalidStateException; use Hypervel\Socialite\Two\Token; use Hypervel\Socialite\Two\User; diff --git a/tests/Socialite/OpenIdProviderTest.php b/tests/Socialite/OpenIdProviderTest.php index ca445348f..9d2e4c05a 100644 --- a/tests/Socialite/OpenIdProviderTest.php +++ b/tests/Socialite/OpenIdProviderTest.php @@ -7,9 +7,9 @@ use GuzzleHttp\Client; use GuzzleHttp\Psr7\Response; use Hypervel\Context\Context; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; +use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Socialite\Two\User; use Hypervel\Tests\Socialite\Fixtures\OpenIdTestProviderStub; use Hypervel\Tests\TestCase; diff --git a/tests/Socialite/SlackOpenIdProviderTest.php b/tests/Socialite/SlackOpenIdProviderTest.php index 16d1eb45f..9b8d621be 100644 --- a/tests/Socialite/SlackOpenIdProviderTest.php +++ b/tests/Socialite/SlackOpenIdProviderTest.php @@ -7,8 +7,8 @@ use GuzzleHttp\Client; use GuzzleHttp\RequestOptions; use Hypervel\Context\Context; -use Hypervel\Http\Contracts\RequestContract; -use Hypervel\Http\Contracts\ResponseContract; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; use Hypervel\Socialite\Contracts\User as UserContract; use Hypervel\Socialite\Two\SlackOpenIdProvider; use Hypervel\Socialite\Two\User; diff --git a/tests/Support/DatabaseIntegrationTestCase.php b/tests/Support/DatabaseIntegrationTestCase.php new file mode 100644 index 000000000..60800b0dd --- /dev/null +++ b/tests/Support/DatabaseIntegrationTestCase.php @@ -0,0 +1,219 @@ +getDatabaseDriver(); + + if ($this->shouldSkipForDriver($driver)) { + $this->markTestSkipped( + "Integration tests for {$driver} are disabled. Set the appropriate RUN_*_INTEGRATION_TESTS=true to enable." + ); + } + + parent::setUp(); + + $this->configureDatabase(); + } + + /** + * Determine if tests should be skipped for the given driver. + */ + protected function shouldSkipForDriver(string $driver): bool + { + return match ($driver) { + 'pgsql' => ! env('RUN_PGSQL_INTEGRATION_TESTS', false), + 'mysql' => ! env('RUN_MYSQL_INTEGRATION_TESTS', false), + 'sqlite' => false, // SQLite tests always run + default => true, + }; + } + + /** + * Configure database connection settings from environment variables. + * + * Uses ParallelTesting to get worker-specific database names when + * running with paratest. + */ + protected function configureDatabase(): void + { + $driver = $this->getDatabaseDriver(); + $config = $this->app->get(ConfigInterface::class); + + $this->registerConnectors($driver); + + $connectionConfig = match ($driver) { + 'mysql' => $this->getMySqlConnectionConfig(), + 'pgsql' => $this->getPostgresConnectionConfig(), + 'sqlite' => $this->getSqliteConnectionConfig(), + default => throw new InvalidArgumentException("Unsupported driver: {$driver}"), + }; + + // Set Hyperf-style config (used by DbPool/ConnectionResolver) + $config->set("databases.{$driver}", $connectionConfig); + $config->set('databases.default', $connectionConfig); + + // Set Laravel-style config (used by RefreshDatabase trait) + $config->set("database.connections.{$driver}", $connectionConfig); + $config->set('database.default', $driver); + } + + /** + * Register database connectors for non-MySQL drivers. + */ + protected function registerConnectors(string $driver): void + { + match ($driver) { + 'pgsql' => $this->app->set('db.connector.pgsql', new PostgresConnector()), + 'sqlite' => $this->app->set('db.connector.sqlite', new SQLiteConnector()), + default => null, + }; + } + + /** + * Get MySQL connection configuration. + * + * @return array + */ + protected function getMySqlConnectionConfig(): array + { + $baseDatabase = env('MYSQL_DATABASE', 'testing'); + + return [ + 'driver' => 'mysql', + 'host' => env('MYSQL_HOST', '127.0.0.1'), + 'port' => (int) env('MYSQL_PORT', 3306), + 'database' => ParallelTesting::databaseName($baseDatabase), + 'username' => env('MYSQL_USERNAME', 'root'), + 'password' => env('MYSQL_PASSWORD', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 60.0, + ], + ]; + } + + /** + * Get PostgreSQL connection configuration. + * + * @return array + */ + protected function getPostgresConnectionConfig(): array + { + $baseDatabase = env('PGSQL_DATABASE', 'testing'); + + return [ + 'driver' => 'pgsql', + 'host' => env('PGSQL_HOST', '127.0.0.1'), + 'port' => (int) env('PGSQL_PORT', 5432), + 'database' => ParallelTesting::databaseName($baseDatabase), + 'username' => env('PGSQL_USERNAME', 'postgres'), + 'password' => env('PGSQL_PASSWORD', ''), + 'charset' => 'utf8', + 'schema' => 'public', + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 60.0, + ], + ]; + } + + /** + * Get SQLite connection configuration. + * + * Uses :memory: for fast in-memory testing. The RegisterSQLiteConnectionListener + * ensures all pooled connections share the same in-memory database by storing + * a persistent PDO in the container. + * + * @return array + */ + protected function getSqliteConnectionConfig(): array + { + return [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + } + + /** + * Get the database driver for this test class. + */ + abstract protected function getDatabaseDriver(): string; + + /** + * Get the schema builder for the test connection. + */ + protected function getSchemaBuilder(): SchemaBuilder + { + return Schema::connection($this->getDatabaseDriver()); + } + + /** + * Get the database connection for the test. + */ + protected function db(): ConnectionInterface + { + return DB::connection($this->getDatabaseDriver()); + } + + /** + * Get the connection name for RefreshDatabase. + */ + protected function getRefreshConnection(): string + { + return $this->getDatabaseDriver(); + } + + /** + * The database connections that should have transactions. + * + * Override to use the test's driver instead of the default connection. + * + * @return array + */ + protected function connectionsToTransact(): array + { + return [$this->getDatabaseDriver()]; + } +} diff --git a/tests/Support/NumberTest.php b/tests/Support/NumberTest.php new file mode 100644 index 000000000..2ca25d1a6 --- /dev/null +++ b/tests/Support/NumberTest.php @@ -0,0 +1,402 @@ +assertSame('1', Number::format(1)); + $this->assertSame('10', Number::format(10)); + $this->assertSame('25', Number::format(25)); + $this->assertSame('100', Number::format(100)); + $this->assertSame('1,000', Number::format(1000)); + $this->assertSame('1,000,000', Number::format(1000000)); + $this->assertSame('123,456,789', Number::format(123456789)); + } + + public function testFormatWithPrecision(): void + { + $this->assertSame('1.00', Number::format(1, precision: 2)); + $this->assertSame('1.20', Number::format(1.2, precision: 2)); + $this->assertSame('1.23', Number::format(1.234, precision: 2)); + $this->assertSame('1.1', Number::format(1.123, maxPrecision: 1)); + } + + public function testFormatWithLocale(): void + { + $this->assertSame('1,234.56', Number::format(1234.56, precision: 2, locale: 'en')); + $this->assertSame('1.234,56', Number::format(1234.56, precision: 2, locale: 'de')); + // French uses non-breaking space as thousands separator + $this->assertStringContainsString('234', Number::format(1234.56, precision: 2, locale: 'fr')); + $this->assertStringContainsString(',56', Number::format(1234.56, precision: 2, locale: 'fr')); + } + + public function testSpell(): void + { + $this->assertSame('one', Number::spell(1)); + $this->assertSame('ten', Number::spell(10)); + $this->assertSame('one hundred twenty-three', Number::spell(123)); + } + + public function testSpellWithAfter(): void + { + $this->assertSame('10', Number::spell(10, after: 10)); + $this->assertSame('eleven', Number::spell(11, after: 10)); + } + + public function testSpellWithUntil(): void + { + $this->assertSame('nine', Number::spell(9, until: 10)); + $this->assertSame('10', Number::spell(10, until: 10)); + } + + public function testOrdinal(): void + { + $this->assertSame('1st', Number::ordinal(1)); + $this->assertSame('2nd', Number::ordinal(2)); + $this->assertSame('3rd', Number::ordinal(3)); + $this->assertSame('4th', Number::ordinal(4)); + $this->assertSame('21st', Number::ordinal(21)); + } + + public function testPercentage(): void + { + $this->assertSame('0%', Number::percentage(0)); + $this->assertSame('1%', Number::percentage(1)); + $this->assertSame('50%', Number::percentage(50)); + $this->assertSame('100%', Number::percentage(100)); + $this->assertSame('12.34%', Number::percentage(12.34, precision: 2)); + } + + public function testCurrency(): void + { + $this->assertSame('$0.00', Number::currency(0)); + $this->assertSame('$1.00', Number::currency(1)); + $this->assertSame('$1,000.00', Number::currency(1000)); + } + + public function testCurrencyWithDifferentCurrency(): void + { + $this->assertStringContainsString('1,000', Number::currency(1000, 'EUR')); + $this->assertStringContainsString('1,000', Number::currency(1000, 'GBP')); + } + + public function testFileSize(): void + { + $this->assertSame('0 B', Number::fileSize(0)); + $this->assertSame('1 B', Number::fileSize(1)); + $this->assertSame('1 KB', Number::fileSize(1024)); + $this->assertSame('1 MB', Number::fileSize(1024 * 1024)); + $this->assertSame('1 GB', Number::fileSize(1024 * 1024 * 1024)); + } + + public function testFileSizeWithPrecision(): void + { + $this->assertSame('1.50 KB', Number::fileSize(1536, precision: 2)); + } + + public function testAbbreviate(): void + { + $this->assertSame('0', Number::abbreviate(0)); + $this->assertSame('1', Number::abbreviate(1)); + $this->assertSame('1K', Number::abbreviate(1000)); + $this->assertSame('1M', Number::abbreviate(1000000)); + $this->assertSame('1B', Number::abbreviate(1000000000)); + } + + public function testForHumans(): void + { + $this->assertSame('0', Number::forHumans(0)); + $this->assertSame('1', Number::forHumans(1)); + $this->assertSame('1 thousand', Number::forHumans(1000)); + $this->assertSame('1 million', Number::forHumans(1000000)); + $this->assertSame('1 billion', Number::forHumans(1000000000)); + } + + public function testClamp(): void + { + $this->assertSame(5, Number::clamp(5, 1, 10)); + $this->assertSame(1, Number::clamp(0, 1, 10)); + $this->assertSame(10, Number::clamp(15, 1, 10)); + $this->assertSame(5.5, Number::clamp(5.5, 1.0, 10.0)); + } + + public function testPairs(): void + { + $this->assertSame([[1, 10], [11, 20], [21, 25]], Number::pairs(25, 10)); + $this->assertSame([[0, 10], [10, 20], [20, 25]], Number::pairs(25, 10, 0)); + } + + public function testTrim(): void + { + $this->assertSame(1, Number::trim(1.0)); + $this->assertSame(1.5, Number::trim(1.50)); + $this->assertSame(1.23, Number::trim(1.230)); + } + + // ========================================================================== + // Context-Based Locale/Currency Tests - These are critical for coroutine safety + // ========================================================================== + + public function testUseLocaleStoresInContext(): void + { + $this->assertSame('en', Number::defaultLocale()); + + Number::useLocale('de'); + + $this->assertSame('de', Number::defaultLocale()); + $this->assertSame('de', Context::get('__support.number.locale')); + } + + public function testUseCurrencyStoresInContext(): void + { + $this->assertSame('USD', Number::defaultCurrency()); + + Number::useCurrency('EUR'); + + $this->assertSame('EUR', Number::defaultCurrency()); + $this->assertSame('EUR', Context::get('__support.number.currency')); + } + + public function testDefaultLocaleReturnsStaticDefaultWhenNotSet(): void + { + $this->assertSame('en', Number::defaultLocale()); + $this->assertNull(Context::get('__support.number.locale')); + } + + public function testDefaultCurrencyReturnsStaticDefaultWhenNotSet(): void + { + $this->assertSame('USD', Number::defaultCurrency()); + $this->assertNull(Context::get('__support.number.currency')); + } + + public function testWithLocaleTemporarilySetsLocale(): void + { + Number::useLocale('en'); + + $result = Number::withLocale('de', function () { + return Number::defaultLocale(); + }); + + $this->assertSame('de', $result); + $this->assertSame('en', Number::defaultLocale()); + } + + public function testWithCurrencyTemporarilySetsCurrency(): void + { + Number::useCurrency('USD'); + + $result = Number::withCurrency('EUR', function () { + return Number::defaultCurrency(); + }); + + $this->assertSame('EUR', $result); + $this->assertSame('USD', Number::defaultCurrency()); + } + + public function testWithLocaleRestoresPreviousContextValue(): void + { + // Set a custom locale first + Number::useLocale('fr'); + + // Then use withLocale to temporarily change it + $result = Number::withLocale('de', function () { + return Number::defaultLocale(); + }); + + // Should have used 'de' during callback + $this->assertSame('de', $result); + // Should restore to 'fr' (the previous Context value), not 'en' (static default) + $this->assertSame('fr', Number::defaultLocale()); + } + + public function testWithCurrencyRestoresPreviousContextValue(): void + { + // Set a custom currency first + Number::useCurrency('GBP'); + + // Then use withCurrency to temporarily change it + $result = Number::withCurrency('EUR', function () { + return Number::defaultCurrency(); + }); + + // Should have used 'EUR' during callback + $this->assertSame('EUR', $result); + // Should restore to 'GBP' (the previous Context value), not 'USD' (static default) + $this->assertSame('GBP', Number::defaultCurrency()); + } + + // ========================================================================== + // Coroutine Isolation Tests - Critical for Swoole coroutine safety + // ========================================================================== + + public function testLocaleIsIsolatedBetweenCoroutines(): void + { + $results = []; + + run(function () use (&$results): void { + $results = parallel([ + function () { + Number::useLocale('de'); + usleep(1000); // Small delay to allow interleaving + return Number::defaultLocale(); + }, + function () { + Number::useLocale('fr'); + usleep(1000); + return Number::defaultLocale(); + }, + ]); + }); + + // Each coroutine should see its own locale, not affected by the other + $this->assertContains('de', $results); + $this->assertContains('fr', $results); + } + + public function testCurrencyIsIsolatedBetweenCoroutines(): void + { + $results = []; + + run(function () use (&$results): void { + $results = parallel([ + function () { + Number::useCurrency('EUR'); + usleep(1000); + return Number::defaultCurrency(); + }, + function () { + Number::useCurrency('GBP'); + usleep(1000); + return Number::defaultCurrency(); + }, + ]); + }); + + // Each coroutine should see its own currency + $this->assertContains('EUR', $results); + $this->assertContains('GBP', $results); + } + + public function testLocaleDoesNotLeakBetweenCoroutines(): void + { + $leakedLocale = null; + + run(function () use (&$leakedLocale): void { + parallel([ + function () { + Number::useLocale('de'); + usleep(5000); // Hold the coroutine + }, + function () use (&$leakedLocale) { + usleep(1000); // Let first coroutine set its locale + // This coroutine should NOT see 'de' from the other coroutine + $leakedLocale = Number::defaultLocale(); + }, + ]); + }); + + // Second coroutine should see the default 'en', not 'de' from first coroutine + $this->assertSame('en', $leakedLocale); + } + + public function testCurrencyDoesNotLeakBetweenCoroutines(): void + { + $leakedCurrency = null; + + run(function () use (&$leakedCurrency): void { + parallel([ + function () { + Number::useCurrency('EUR'); + usleep(5000); + }, + function () use (&$leakedCurrency) { + usleep(1000); + $leakedCurrency = Number::defaultCurrency(); + }, + ]); + }); + + $this->assertSame('USD', $leakedCurrency); + } + + // ========================================================================== + // Regression Tests - Prevent the SUP-01 bug from recurring + // ========================================================================== + + /** + * Regression test for SUP-01: useCurrency was using Context::get instead of Context::set. + */ + public function testUseCurrencyActuallySetsValue(): void + { + // Before the fix, useCurrency() called Context::get() which doesn't set anything + $this->assertNull(Context::get('__support.number.currency')); + + Number::useCurrency('JPY'); + + // After calling useCurrency, the value should be set in Context + $this->assertSame('JPY', Context::get('__support.number.currency')); + $this->assertSame('JPY', Number::defaultCurrency()); + } + + /** + * Ensures useCurrency changes actually affect subsequent currency() calls + * when using defaultCurrency(). + */ + public function testUseCurrencyAffectsDefaultCurrency(): void + { + // Set currency and verify defaultCurrency returns it + Number::useCurrency('CAD'); + $this->assertSame('CAD', Number::defaultCurrency()); + + // Change it again + Number::useCurrency('AUD'); + $this->assertSame('AUD', Number::defaultCurrency()); + } + + /** + * Ensures useLocale changes actually affect subsequent formatting calls + * when using defaultLocale(). + */ + public function testUseLocaleAffectsDefaultLocale(): void + { + Number::useLocale('ja'); + $this->assertSame('ja', Number::defaultLocale()); + + Number::useLocale('zh'); + $this->assertSame('zh', Number::defaultLocale()); + } +} diff --git a/tests/Support/OnceTest.php b/tests/Support/OnceTest.php new file mode 100644 index 000000000..3f7a4ffda --- /dev/null +++ b/tests/Support/OnceTest.php @@ -0,0 +1,88 @@ +newCounter(); + + $first = $this->runOnceWithCounter($counter); + $second = $this->runOnceWithCounter($counter); + + $this->assertSame(1, $first); + $this->assertSame(1, $second); + $this->assertSame(1, $counter->value); + } + + public function testOnceDifferentiatesClosureUses(): void + { + $results = array_map( + fn (int $value) => once(fn () => $value), + [1, 2], + ); + + $this->assertSame([1, 2], $results); + } + + public function testOnceIsCoroutineScoped(): void + { + $counter = $this->newCounter(); + $results = []; + + run(function () use (&$results, $counter): void { + $results = parallel([ + fn () => $this->runOnceWithCounter($counter), + fn () => $this->runOnceWithCounter($counter), + ]); + }); + + sort($results); + + $this->assertSame([1, 2], $results); + $this->assertSame(2, $counter->value); + } + + private function newCounter(): object + { + return new class { + public int $value = 0; + }; + } + + private function runOnceWithCounter(object $counter): int + { + return once(function () use ($counter): int { + return ++$counter->value; + }); + } +} diff --git a/tests/Support/OnceableTest.php b/tests/Support/OnceableTest.php new file mode 100644 index 000000000..10c274500 --- /dev/null +++ b/tests/Support/OnceableTest.php @@ -0,0 +1,66 @@ +createOnceable(fn () => 'value'); + + $this->assertSame($this, $onceable->object); + } + + public function testHashUsesOnceHashImplementation(): void + { + $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2); + + $value = new OnceHashStub('same'); + $onceableA = Onceable::tryFromTrace($trace, fn () => $value); + + $value = new OnceHashStub('same'); + $onceableB = Onceable::tryFromTrace($trace, fn () => $value); + + $value = new OnceHashStub('different'); + $onceableC = Onceable::tryFromTrace($trace, fn () => $value); + + $this->assertNotNull($onceableA); + $this->assertNotNull($onceableB); + $this->assertNotNull($onceableC); + $this->assertSame($onceableA->hash, $onceableB->hash); + $this->assertNotSame($onceableA->hash, $onceableC->hash); + } + + private function createOnceable(callable $callback): Onceable + { + $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2); + + $onceable = Onceable::tryFromTrace($trace, $callback); + + $this->assertNotNull($onceable); + + return $onceable; + } +} + +class OnceHashStub implements HasOnceHash +{ + public function __construct(private string $hash) + { + } + + public function onceHash(): string + { + return $this->hash; + } +} diff --git a/tests/Support/ParallelTesting.php b/tests/Support/ParallelTesting.php new file mode 100644 index 000000000..5eaf34794 --- /dev/null +++ b/tests/Support/ParallelTesting.php @@ -0,0 +1,74 @@ +assertSame('hello', Str::from('hello')); - $this->assertSame('', Str::from('')); - $this->assertSame('with spaces', Str::from('with spaces')); - } - - public function testFromWithInt(): void - { - $result = Str::from(42); - - $this->assertIsString($result); - $this->assertSame('42', $result); - $this->assertSame('0', Str::from(0)); - $this->assertSame('-1', Str::from(-1)); - } - - public function testFromWithStringBackedEnum(): void - { - $this->assertSame('active', Str::from(TestStringStatus::Active)); - $this->assertSame('pending', Str::from(TestStringStatus::Pending)); - $this->assertSame('archived', Str::from(TestStringStatus::Archived)); - } - - public function testFromWithIntBackedEnum(): void - { - $result = Str::from(TestIntStatus::Ok); - - $this->assertIsString($result); - $this->assertSame('200', $result); - $this->assertSame('404', Str::from(TestIntStatus::NotFound)); - $this->assertSame('500', Str::from(TestIntStatus::ServerError)); - } - - public function testFromWithStringable(): void - { - $this->assertSame('stringable-value', Str::from(new TestStringable('stringable-value'))); - $this->assertSame('', Str::from(new TestStringable(''))); - $this->assertSame('with spaces', Str::from(new TestStringable('with spaces'))); - } - - public function testFromAllWithStrings(): void - { - $result = Str::fromAll(['users', 'posts', 'comments']); - - $this->assertSame(['users', 'posts', 'comments'], $result); - } - - public function testFromAllWithEnums(): void - { - $result = Str::fromAll([ - TestStringStatus::Active, - TestStringStatus::Pending, - TestStringStatus::Archived, - ]); - - $this->assertSame(['active', 'pending', 'archived'], $result); - } - - public function testFromAllWithIntBackedEnums(): void - { - $result = Str::fromAll([ - TestIntStatus::Ok, - TestIntStatus::NotFound, - ]); - - $this->assertSame(['200', '404'], $result); - } - - public function testFromAllWithStringables(): void - { - $result = Str::fromAll([ - new TestStringable('first'), - new TestStringable('second'), - ]); - - $this->assertSame(['first', 'second'], $result); - } - - public function testFromAllWithMixedInput(): void - { - $result = Str::fromAll([ - 'users', - TestStringStatus::Active, - 42, - TestIntStatus::NotFound, - new TestStringable('dynamic-tag'), - 'legacy-tag', - ]); - - $this->assertSame(['users', 'active', '42', '404', 'dynamic-tag', 'legacy-tag'], $result); - } - - public function testFromAllWithEmptyArray(): void - { - $this->assertSame([], Str::fromAll([])); - } - - public function testFromAllPreservesArrayKeys(): void - { - $result = Str::fromAll([ - 'first' => TestStringStatus::Active, - 'second' => 'manual', - 0 => TestIntStatus::Ok, - ]); - - $this->assertSame([ - 'first' => 'active', - 'second' => 'manual', - 0 => '200', - ], $result); - } - - #[DataProvider('fromDataProvider')] - public function testFromWithDataProvider(string|int|BackedEnum|Stringable $input, string $expected): void - { - $this->assertSame($expected, Str::from($input)); - } - - public static function fromDataProvider(): iterable - { - yield 'string value' => ['hello', 'hello']; - yield 'empty string' => ['', '']; - yield 'integer' => [123, '123']; - yield 'zero' => [0, '0']; - yield 'negative integer' => [-42, '-42']; - yield 'string-backed enum' => [TestStringStatus::Active, 'active']; - yield 'int-backed enum' => [TestIntStatus::Ok, '200']; - yield 'stringable' => [new TestStringable('from-stringable'), 'from-stringable']; - } -} - -enum TestStringStatus: string -{ - case Active = 'active'; - case Pending = 'pending'; - case Archived = 'archived'; -} - -enum TestIntStatus: int -{ - case Ok = 200; - case NotFound = 404; - case ServerError = 500; -} - -class TestStringable implements Stringable -{ - public function __construct( - private readonly string $value, - ) { - } - - public function __toString(): string - { - return $this->value; - } -} diff --git a/tests/Support/SupportServiceProviderTest.php b/tests/Support/SupportServiceProviderTest.php index 725c7e818..c5c2de627 100644 --- a/tests/Support/SupportServiceProviderTest.php +++ b/tests/Support/SupportServiceProviderTest.php @@ -31,11 +31,6 @@ protected function setUp(): void $two->boot(); } - protected function tearDown(): void - { - m::close(); - } - public function testPublishableServiceProviders() { $toPublish = ServiceProvider::publishableProviders(); diff --git a/tests/Telescope/FeatureTestCase.php b/tests/Telescope/FeatureTestCase.php index 830f64b0b..1aa97f853 100644 --- a/tests/Telescope/FeatureTestCase.php +++ b/tests/Telescope/FeatureTestCase.php @@ -7,10 +7,10 @@ use Faker\Factory as FakerFactory; use Faker\Generator; use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\Model\Collection; -use Hyperf\Database\Schema\Blueprint; -use Hypervel\Cache\Contracts\Factory as CacheFactoryContract; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Cache\Factory as CacheFactoryContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Queue\Queue; diff --git a/tests/Telescope/Http/AuthorizationTest.php b/tests/Telescope/Http/AuthorizationTest.php index 98d056e14..c791d66c5 100644 --- a/tests/Telescope/Http/AuthorizationTest.php +++ b/tests/Telescope/Http/AuthorizationTest.php @@ -5,9 +5,9 @@ namespace Hypervel\Tests\Telescope\Http; use Hypervel\Auth\Access\Gate; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Gate as GateContract; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Contracts\Auth\Access\Gate as GateContract; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Http\Request as RequestContract; use Hypervel\Telescope\Telescope; use Hypervel\Tests\Telescope\FeatureTestCase; diff --git a/tests/Telescope/Http/AvatarTest.php b/tests/Telescope/Http/AvatarTest.php index 62e0a6cb6..1a0915b47 100644 --- a/tests/Telescope/Http/AvatarTest.php +++ b/tests/Telescope/Http/AvatarTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Telescope\Http; use Hyperf\Contract\ConfigInterface; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Database\Eloquent\Model; use Hypervel\Telescope\Http\Middleware\Authorize; use Hypervel\Telescope\Telescope; diff --git a/tests/Telescope/TelescopeTest.php b/tests/Telescope/TelescopeTest.php index 28fae46b7..1e755cd54 100644 --- a/tests/Telescope/TelescopeTest.php +++ b/tests/Telescope/TelescopeTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\Telescope; use Hyperf\Contract\ConfigInterface; -use Hypervel\Bus\Contracts\Dispatcher; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Storage\EntryModel; diff --git a/tests/Telescope/Watchers/CacheWatcherTest.php b/tests/Telescope/Watchers/CacheWatcherTest.php index b38361787..b647be563 100644 --- a/tests/Telescope/Watchers/CacheWatcherTest.php +++ b/tests/Telescope/Watchers/CacheWatcherTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Telescope\Watchers; use Hyperf\Contract\ConfigInterface; -use Hypervel\Cache\Contracts\Factory as FactoryContract; +use Hypervel\Contracts\Cache\Factory as FactoryContract; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\CacheWatcher; diff --git a/tests/Telescope/Watchers/CommandWatcherTest.php b/tests/Telescope/Watchers/CommandWatcherTest.php index e097488b6..d06c1c205 100644 --- a/tests/Telescope/Watchers/CommandWatcherTest.php +++ b/tests/Telescope/Watchers/CommandWatcherTest.php @@ -6,7 +6,7 @@ use Hyperf\Contract\ConfigInterface; use Hypervel\Console\Command; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Watchers\CommandWatcher; use Hypervel\Tests\Telescope\FeatureTestCase; diff --git a/tests/Telescope/Watchers/ExceptionWatcherTest.php b/tests/Telescope/Watchers/ExceptionWatcherTest.php index 76d061e95..2582fdc5e 100644 --- a/tests/Telescope/Watchers/ExceptionWatcherTest.php +++ b/tests/Telescope/Watchers/ExceptionWatcherTest.php @@ -8,7 +8,7 @@ use ErrorException; use Exception; use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Watchers\ExceptionWatcher; use Hypervel\Tests\Telescope\FeatureTestCase; diff --git a/tests/Telescope/Watchers/GateWatcherTest.php b/tests/Telescope/Watchers/GateWatcherTest.php index c223c8382..4670b1b5c 100644 --- a/tests/Telescope/Watchers/GateWatcherTest.php +++ b/tests/Telescope/Watchers/GateWatcherTest.php @@ -9,8 +9,8 @@ use Hypervel\Auth\Access\AuthorizesRequests; use Hypervel\Auth\Access\Gate; use Hypervel\Auth\Access\Response; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Gate as GateContract; +use Hypervel\Contracts\Auth\Access\Gate as GateContract; +use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Watchers\GateWatcher; use Hypervel\Tests\Telescope\FeatureTestCase; diff --git a/tests/Telescope/Watchers/JobWatcherTest.php b/tests/Telescope/Watchers/JobWatcherTest.php index c5442ccf1..7c5e13451 100644 --- a/tests/Telescope/Watchers/JobWatcherTest.php +++ b/tests/Telescope/Watchers/JobWatcherTest.php @@ -7,9 +7,9 @@ use Exception; use Hyperf\Contract\ConfigInterface; use Hypervel\Bus\Batch; -use Hypervel\Bus\Contracts\BatchRepository; use Hypervel\Bus\Dispatchable; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Bus\BatchRepository; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobProcessed; use Hypervel\Queue\Jobs\FakeJob; diff --git a/tests/Telescope/Watchers/ModelWatcherTest.php b/tests/Telescope/Watchers/ModelWatcherTest.php index 310051677..411c589a9 100644 --- a/tests/Telescope/Watchers/ModelWatcherTest.php +++ b/tests/Telescope/Watchers/ModelWatcherTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\Telescope\Watchers; use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\Str; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\ModelWatcher; @@ -26,10 +26,10 @@ protected function setUp(): void ->set('telescope.watchers', [ ModelWatcher::class => [ 'enabled' => true, - 'events' => [ - \Hyperf\Database\Model\Events\Created::class, - \Hyperf\Database\Model\Events\Updated::class, - \Hyperf\Database\Model\Events\Retrieved::class, + 'actions' => [ + 'created', + 'updated', + 'retrieved', ], 'hydrations' => true, ], diff --git a/tests/Telescope/Watchers/QueryWatcherTest.php b/tests/Telescope/Watchers/QueryWatcherTest.php index d716299ab..fe02dd87c 100644 --- a/tests/Telescope/Watchers/QueryWatcherTest.php +++ b/tests/Telescope/Watchers/QueryWatcherTest.php @@ -4,15 +4,19 @@ namespace Hypervel\Tests\Telescope\Watchers; +use Exception; use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\Connection; -use Hyperf\Database\Events\QueryExecuted; +use Hypervel\Database\Connection; +use Hypervel\Database\Events\QueryExecuted; use Hypervel\Support\Carbon; use Hypervel\Support\Facades\DB; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Storage\EntryModel; use Hypervel\Telescope\Watchers\QueryWatcher; use Hypervel\Tests\Telescope\FeatureTestCase; +use PDO; +use PDOException; +use ReflectionProperty; /** * @internal @@ -121,7 +125,19 @@ public function testQueryWatcherCanPrepareBindingsForNonstandardConnections() SQL, ['kp_id' => '=ABC001'], 500, - new Connection('filemaker'), + new class(fn () => null, '', '', ['name' => 'filemaker']) extends Connection { + public function getName(): string + { + return $this->config['name']; + } + + public function getPdo(): PDO + { + $e = new PDOException('Driver does not support this function'); + (new ReflectionProperty(Exception::class, 'code'))->setValue($e, 'IM001'); + throw $e; + } + }, ); $sql = $this->app->get(QueryWatcher::class)->replaceBindings($event); diff --git a/tests/TestCase.php b/tests/TestCase.php index f70d6c116..034b1d7cc 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,6 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; -use Mockery; use PHPUnit\Framework\TestCase as BaseTestCase; /** @@ -17,12 +16,6 @@ class TestCase extends BaseTestCase { protected function tearDown(): void { - if ($container = Mockery::getContainer()) { - $this->addToAssertionCount($container->mockery_getExpectationCount()); - } - - Mockery::close(); - Carbon::setTestNow(); CarbonImmutable::setTestNow(); } diff --git a/tests/Testbench/Concerns/CreatesApplicationTest.php b/tests/Testbench/Concerns/CreatesApplicationTest.php new file mode 100644 index 000000000..ca2bde2ad --- /dev/null +++ b/tests/Testbench/Concerns/CreatesApplicationTest.php @@ -0,0 +1,83 @@ + TestFacade::class, + ]; + } + + public function testGetPackageProvidersReturnsProviders(): void + { + $providers = $this->getPackageProviders($this->app); + + $this->assertContains(TestServiceProvider::class, $providers); + } + + public function testGetPackageAliasesReturnsAliases(): void + { + $aliases = $this->getPackageAliases($this->app); + + $this->assertArrayHasKey('TestAlias', $aliases); + $this->assertSame(TestFacade::class, $aliases['TestAlias']); + } + + public function testRegisterPackageProvidersRegistersProviders(): void + { + // The provider should be registered via defineEnvironment + // which calls registerPackageProviders + $this->assertTrue( + $this->app->providerIsLoaded(TestServiceProvider::class), + 'TestServiceProvider should be registered' + ); + } + + public function testRegisterPackageAliasesAddsToConfig(): void + { + $aliases = $this->app->get('config')->get('app.aliases', []); + + $this->assertArrayHasKey('TestAlias', $aliases); + $this->assertSame(TestFacade::class, $aliases['TestAlias']); + } +} + +/** + * Test service provider for testing. + */ +class TestServiceProvider extends \Hypervel\Support\ServiceProvider +{ + public function register(): void + { + $this->app->bind('test.service', fn () => 'test_value'); + } +} + +/** + * Test facade for testing. + */ +class TestFacade +{ + // Empty facade class for testing +} diff --git a/tests/Testbench/Concerns/HandlesRoutesTest.php b/tests/Testbench/Concerns/HandlesRoutesTest.php new file mode 100644 index 000000000..e8b98070a --- /dev/null +++ b/tests/Testbench/Concerns/HandlesRoutesTest.php @@ -0,0 +1,82 @@ +defineRoutesCalled = true; + + $router->get('/api/test', fn () => 'api_response'); + } + + protected function defineWebRoutes(Router $router): void + { + $this->defineWebRoutesCalled = true; + + // Note: Web routes are wrapped in 'web' middleware group by setUpApplicationRoutes + // We register a simple route here just to verify the method is called + $router->get('/web/test', fn () => 'web_response'); + } + + public function testDefineRoutesMethodExists(): void + { + $this->assertTrue(method_exists($this, 'defineRoutes')); + } + + public function testDefineWebRoutesMethodExists(): void + { + $this->assertTrue(method_exists($this, 'defineWebRoutes')); + } + + public function testSetUpApplicationRoutesMethodExists(): void + { + $this->assertTrue(method_exists($this, 'setUpApplicationRoutes')); + } + + public function testSetUpApplicationRoutesCallsDefineRoutes(): void + { + // setUpApplicationRoutes is called automatically in setUp via afterApplicationCreated + // so defineRoutesCalled should already be true + $this->assertTrue($this->defineRoutesCalled); + } + + public function testSetUpApplicationRoutesCallsDefineWebRoutes(): void + { + // setUpApplicationRoutes is called automatically in setUp via afterApplicationCreated + // so defineWebRoutesCalled should already be true + $this->assertTrue($this->defineWebRoutesCalled); + } + + public function testRouterIsPassedToDefineRoutes(): void + { + $router = $this->app->get(Router::class); + + $this->assertInstanceOf(Router::class, $router); + } + + public function testDefinedRoutesAreAccessibleViaHttp(): void + { + $response = $this->get('/api/test'); + + $response->assertSuccessful(); + $response->assertContent('api_response'); + } +} diff --git a/tests/Testbench/Concerns/HandlesRoutesWithoutWebRoutesTest.php b/tests/Testbench/Concerns/HandlesRoutesWithoutWebRoutesTest.php new file mode 100644 index 000000000..bd86e9002 --- /dev/null +++ b/tests/Testbench/Concerns/HandlesRoutesWithoutWebRoutesTest.php @@ -0,0 +1,30 @@ +get('/only-api', fn () => 'only_api_response'); + } + + public function testRoutesWorkWithoutDefineWebRoutes(): void + { + $this->get('/only-api')->assertSuccessful()->assertContent('only_api_response'); + } +} diff --git a/tests/Testbench/TestCaseTest.php b/tests/Testbench/TestCaseTest.php new file mode 100644 index 000000000..dfb7a29fd --- /dev/null +++ b/tests/Testbench/TestCaseTest.php @@ -0,0 +1,107 @@ +defineEnvironmentCalled = true; + $app->get('config')->set('testing.define_environment', 'called'); + } + + public function testTestCaseUsesCreatesApplicationTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(CreatesApplication::class, $uses); + } + + public function testTestCaseUsesHandlesRoutesTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(HandlesRoutes::class, $uses); + } + + public function testTestCaseUsesHandlesDatabasesTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(HandlesDatabases::class, $uses); + } + + public function testTestCaseUsesHandlesAttributesTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(HandlesAttributes::class, $uses); + } + + public function testTestCaseUsesInteractsWithTestCaseTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(InteractsWithTestCase::class, $uses); + } + + public function testDefineEnvironmentIsCalled(): void + { + $this->assertTrue($this->defineEnvironmentCalled); + $this->assertSame('called', $this->app->get('config')->get('testing.define_environment')); + } + + public function testClassLevelAttributeIsApplied(): void + { + // The WithConfig attribute at class level should be applied + $this->assertSame('class_level', $this->app->get('config')->get('testing.testcase_class')); + } + + #[WithConfig('testing.method_attribute', 'method_level')] + public function testMethodLevelAttributeIsApplied(): void + { + // The WithConfig attribute at method level should be applied + $this->assertSame('method_level', $this->app->get('config')->get('testing.method_attribute')); + } + + public function testReloadApplicationMethodExists(): void + { + $this->assertTrue(method_exists($this, 'reloadApplication')); + } + + public function testStaticLifecycleMethodsExist(): void + { + $this->assertTrue(method_exists(static::class, 'setUpBeforeClass')); + $this->assertTrue(method_exists(static::class, 'tearDownAfterClass')); + } + + public function testUsesTestingConcernIsAvailable(): void + { + $this->assertTrue(static::usesTestingConcern(HandlesAttributes::class)); + } + + public function testAppIsAvailable(): void + { + $this->assertNotNull($this->app); + } +} diff --git a/tests/Translation/FileLoaderTest.php b/tests/Translation/FileLoaderTest.php index 0ba430c49..a5af94cb9 100644 --- a/tests/Translation/FileLoaderTest.php +++ b/tests/Translation/FileLoaderTest.php @@ -16,11 +16,6 @@ */ class FileLoaderTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testLoadMethodLoadsTranslationsFromAddedPath() { $files = m::mock(Filesystem::class); diff --git a/tests/Translation/TranslatorTest.php b/tests/Translation/TranslatorTest.php index d421166a5..45bbe687c 100644 --- a/tests/Translation/TranslatorTest.php +++ b/tests/Translation/TranslatorTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\Translation; +use Hypervel\Contracts\Translation\Loader; use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Carbon; use Hypervel\Support\Collection; -use Hypervel\Translation\Contracts\Loader; use Hypervel\Translation\MessageSelector; use Hypervel\Translation\Translator; use Mockery as m; @@ -36,11 +36,6 @@ enum TranslatorTestUnitEnum */ class TranslatorTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testHasMethodReturnsFalseWhenReturnedTranslationIsNull() { $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); diff --git a/tests/Validation/ValidationAnyOfRuleTest.php b/tests/Validation/ValidationAnyOfRuleTest.php index 556cc89b1..4edc98396 100644 --- a/tests/Validation/ValidationAnyOfRuleTest.php +++ b/tests/Validation/ValidationAnyOfRuleTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Testbench\TestCase; use Hypervel\Translation\ArrayLoader; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Hypervel\Translation\Translator; use Hypervel\Validation\Rule; use Hypervel\Validation\Validator; diff --git a/tests/Validation/ValidationContainsRuleTest.php b/tests/Validation/ValidationContainsRuleTest.php new file mode 100644 index 000000000..4a21e3ae5 --- /dev/null +++ b/tests/Validation/ValidationContainsRuleTest.php @@ -0,0 +1,98 @@ +assertSame('contains:"foo","bar"', (string) $rule); + + $rule = new Contains(collect(['foo', 'bar'])); + + $this->assertSame('contains:"foo","bar"', (string) $rule); + + $rule = new Contains(['value with "quotes"']); + + $this->assertSame('contains:"value with ""quotes"""', (string) $rule); + + $rule = Rule::contains(['foo', 'bar']); + + $this->assertSame('contains:"foo","bar"', (string) $rule); + + $rule = Rule::contains(collect([1, 2, 3])); + + $this->assertSame('contains:"1","2","3"', (string) $rule); + + $rule = Rule::contains(new Values()); + + $this->assertSame('contains:"1","2","3","4"', (string) $rule); + + $rule = Rule::contains('foo', 'bar', 'baz'); + + $this->assertSame('contains:"foo","bar","baz"', (string) $rule); + + $rule = new Contains('foo', 'bar', 'baz'); + + $this->assertSame('contains:"foo","bar","baz"', (string) $rule); + + $rule = Rule::contains([StringStatus::done]); + + $this->assertSame('contains:"done"', (string) $rule); + + $rule = Rule::contains([IntegerStatus::done]); + + $this->assertSame('contains:"2"', (string) $rule); + + $rule = Rule::contains([PureEnum::one]); + + $this->assertSame('contains:"one"', (string) $rule); + } + + public function testContainsRuleValidation() + { + $trans = new Translator(new ArrayLoader(), 'en'); + + // Array contains the required value + $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::contains('foo')]); + $this->assertTrue($v->passes()); + + // Array contains multiple required values + $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::contains('foo', 'bar')]); + $this->assertTrue($v->passes()); + + // Array missing a required value + $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => Rule::contains('baz')]); + $this->assertFalse($v->passes()); + + // Array missing one of multiple required values + $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => Rule::contains('foo', 'qux')]); + $this->assertFalse($v->passes()); + + // Non-array value fails + $v = new Validator($trans, ['x' => 'foo'], ['x' => Rule::contains('foo')]); + $this->assertFalse($v->passes()); + + // Combined with other rules + $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => ['required', 'array', Rule::contains('foo')]]); + $this->assertTrue($v->passes()); + } +} diff --git a/tests/Validation/ValidationDatabasePresenceVerifierTest.php b/tests/Validation/ValidationDatabasePresenceVerifierTest.php index 21e65659e..80725e4ba 100644 --- a/tests/Validation/ValidationDatabasePresenceVerifierTest.php +++ b/tests/Validation/ValidationDatabasePresenceVerifierTest.php @@ -5,9 +5,9 @@ namespace Hypervel\Tests\Validation; use Closure; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Database\Query\Builder; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\Query\Builder; use Hypervel\Validation\DatabasePresenceVerifier; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -18,11 +18,6 @@ */ class ValidationDatabasePresenceVerifierTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testBasicCount() { $verifier = new DatabasePresenceVerifier($db = m::mock(ConnectionResolverInterface::class)); @@ -61,6 +56,8 @@ public function testBasicCountWithClosures() $builder->shouldReceive('where')->with('not', '!=', 'admin'); $builder->shouldReceive('where')->with(m::type(Closure::class))->andReturnUsing(function () use ($builder, $closure) { $closure($builder); + + return $builder; }); $builder->shouldReceive('where')->with('closure', 1); $builder->shouldReceive('count')->once()->andReturn(100); diff --git a/tests/Validation/ValidationDoesntContainRuleTest.php b/tests/Validation/ValidationDoesntContainRuleTest.php new file mode 100644 index 000000000..830f2d0a6 --- /dev/null +++ b/tests/Validation/ValidationDoesntContainRuleTest.php @@ -0,0 +1,98 @@ +assertSame('doesnt_contain:"foo","bar"', (string) $rule); + + $rule = new DoesntContain(collect(['foo', 'bar'])); + + $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule); + + $rule = new DoesntContain(['value with "quotes"']); + + $this->assertSame('doesnt_contain:"value with ""quotes"""', (string) $rule); + + $rule = Rule::doesntContain(['foo', 'bar']); + + $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule); + + $rule = Rule::doesntContain(collect([1, 2, 3])); + + $this->assertSame('doesnt_contain:"1","2","3"', (string) $rule); + + $rule = Rule::doesntContain(new Values()); + + $this->assertSame('doesnt_contain:"1","2","3","4"', (string) $rule); + + $rule = Rule::doesntContain('foo', 'bar', 'baz'); + + $this->assertSame('doesnt_contain:"foo","bar","baz"', (string) $rule); + + $rule = new DoesntContain('foo', 'bar', 'baz'); + + $this->assertSame('doesnt_contain:"foo","bar","baz"', (string) $rule); + + $rule = Rule::doesntContain([StringStatus::done]); + + $this->assertSame('doesnt_contain:"done"', (string) $rule); + + $rule = Rule::doesntContain([IntegerStatus::done]); + + $this->assertSame('doesnt_contain:"2"', (string) $rule); + + $rule = Rule::doesntContain([PureEnum::one]); + + $this->assertSame('doesnt_contain:"one"', (string) $rule); + } + + public function testDoesntContainRuleValidation() + { + $trans = new Translator(new ArrayLoader(), 'en'); + + // Array doesn't contain the forbidden value + $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('qux')]); + $this->assertTrue($v->passes()); + + // Array doesn't contain any of the forbidden values + $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('qux', 'quux')]); + $this->assertTrue($v->passes()); + + // Array contains a forbidden value + $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('foo')]); + $this->assertFalse($v->passes()); + + // Array contains one of the forbidden values + $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('qux', 'bar')]); + $this->assertFalse($v->passes()); + + // Non-array value fails + $v = new Validator($trans, ['x' => 'foo'], ['x' => Rule::doesntContain('foo')]); + $this->assertFalse($v->passes()); + + // Combined with other rules + $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => ['required', 'array', Rule::doesntContain('baz')]]); + $this->assertTrue($v->passes()); + } +} diff --git a/tests/Validation/ValidationEmailRuleTest.php b/tests/Validation/ValidationEmailRuleTest.php index 314a69919..a19c97510 100644 --- a/tests/Validation/ValidationEmailRuleTest.php +++ b/tests/Validation/ValidationEmailRuleTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Support\Arr; use Hypervel\Testbench\TestCase; use Hypervel\Translation\ArrayLoader; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Hypervel\Translation\Translator; use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\Email; diff --git a/tests/Validation/ValidationEnumRuleTest.php b/tests/Validation/ValidationEnumRuleTest.php index 6cda6aebb..a35c13ba1 100644 --- a/tests/Validation/ValidationEnumRuleTest.php +++ b/tests/Validation/ValidationEnumRuleTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Validation; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Support\Collection; use Hypervel\Testbench\TestCase; use Hypervel\Translation\ArrayLoader; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Hypervel\Translation\Translator; use Hypervel\Validation\Rules\Enum; use Hypervel\Validation\Validator; diff --git a/tests/Validation/ValidationExistsRuleTest.php b/tests/Validation/ValidationExistsRuleTest.php index e3510f67b..50f80bbc5 100644 --- a/tests/Validation/ValidationExistsRuleTest.php +++ b/tests/Validation/ValidationExistsRuleTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Validation; -use Hyperf\Database\ConnectionResolverInterface; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Eloquent\Model as Eloquent; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Testbench\TestCase; @@ -13,6 +13,7 @@ use Hypervel\Validation\DatabasePresenceVerifier; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Validator; +use UnitEnum; /** * @internal @@ -308,7 +309,7 @@ class UserWithPrefixedTable extends Eloquent class UserWithConnection extends User { - protected ?string $connection = 'mysql'; + protected UnitEnum|string|null $connection = 'mysql'; } class NoTableNameModel extends Eloquent diff --git a/tests/Validation/ValidationFactoryTest.php b/tests/Validation/ValidationFactoryTest.php index 57c827460..8ace177fa 100755 --- a/tests/Validation/ValidationFactoryTest.php +++ b/tests/Validation/ValidationFactoryTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Validation; use Faker\Container\ContainerInterface; -use Hypervel\Translation\Contracts\Translator as TranslatorInterface; +use Hypervel\Contracts\Translation\Translator as TranslatorInterface; use Hypervel\Validation\Factory; use Hypervel\Validation\PresenceVerifierInterface; use Hypervel\Validation\Validator; @@ -18,11 +18,6 @@ */ class ValidationFactoryTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testMakeMethodCreatesValidValidator() { $translator = m::mock(TranslatorInterface::class); diff --git a/tests/Validation/ValidationFileRuleTest.php b/tests/Validation/ValidationFileRuleTest.php index b220a220a..24e6eec41 100644 --- a/tests/Validation/ValidationFileRuleTest.php +++ b/tests/Validation/ValidationFileRuleTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Testbench\TestCase; use Hypervel\Translation\ArrayLoader; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Hypervel\Translation\Translator; use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\File; diff --git a/tests/Validation/ValidationImageFileRuleTest.php b/tests/Validation/ValidationImageFileRuleTest.php index c65822d6e..cda698e20 100644 --- a/tests/Validation/ValidationImageFileRuleTest.php +++ b/tests/Validation/ValidationImageFileRuleTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Testbench\TestCase; use Hypervel\Translation\ArrayLoader; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Hypervel\Translation\Translator; use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\File; diff --git a/tests/Validation/ValidationInvokableRuleTest.php b/tests/Validation/ValidationInvokableRuleTest.php index e3a96ec73..ffd4c232e 100644 --- a/tests/Validation/ValidationInvokableRuleTest.php +++ b/tests/Validation/ValidationInvokableRuleTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Contracts\Validation\DataAwareRule; +use Hypervel\Contracts\Validation\ValidationRule; +use Hypervel\Contracts\Validation\Validator as ValidatorContract; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Translation\ArrayLoader; use Hypervel\Translation\Translator; -use Hypervel\Validation\Contracts\DataAwareRule; -use Hypervel\Validation\Contracts\ValidationRule; -use Hypervel\Validation\Contracts\Validator as ValidatorContract; -use Hypervel\Validation\Contracts\ValidatorAwareRule; use Hypervel\Validation\InvokableValidationRule; use Hypervel\Validation\Validator; use PHPUnit\Framework\TestCase; diff --git a/tests/Validation/ValidationNotPwnedVerifierTest.php b/tests/Validation/ValidationNotPwnedVerifierTest.php index 508b0f2ea..101a501b7 100644 --- a/tests/Validation/ValidationNotPwnedVerifierTest.php +++ b/tests/Validation/ValidationNotPwnedVerifierTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Validation; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\HttpClient\ConnectionException; use Hypervel\HttpClient\Factory as HttpFactory; use Hypervel\HttpClient\Response; diff --git a/tests/Validation/ValidationPasswordRuleTest.php b/tests/Validation/ValidationPasswordRuleTest.php index e92c80bc0..ae4166fb4 100644 --- a/tests/Validation/ValidationPasswordRuleTest.php +++ b/tests/Validation/ValidationPasswordRuleTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; +use Hypervel\Contracts\Validation\Rule as RuleContract; +use Hypervel\Contracts\Validation\UncompromisedVerifier; use Hypervel\Testbench\TestCase; use Hypervel\Translation\ArrayLoader; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Hypervel\Translation\Translator; -use Hypervel\Validation\Contracts\Rule as RuleContract; -use Hypervel\Validation\Contracts\UncompromisedVerifier; use Hypervel\Validation\Rules\Password; use Hypervel\Validation\Validator; use Mockery as m; diff --git a/tests/Validation/ValidationRuleCanTest.php b/tests/Validation/ValidationRuleCanTest.php index db3db9257..8dd64955d 100644 --- a/tests/Validation/ValidationRuleCanTest.php +++ b/tests/Validation/ValidationRuleCanTest.php @@ -5,11 +5,11 @@ namespace Hypervel\Tests\Validation; use Hypervel\Auth\Access\Gate; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Gate as GateContract; +use Hypervel\Contracts\Auth\Access\Gate as GateContract; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Testbench\TestCase; use Hypervel\Translation\ArrayLoader; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Hypervel\Translation\Translator; use Hypervel\Validation\Rules\Can; use Hypervel\Validation\Validator; diff --git a/tests/Validation/ValidationUniqueRuleTest.php b/tests/Validation/ValidationUniqueRuleTest.php index 7460f655b..ce3fa80b5 100644 --- a/tests/Validation/ValidationUniqueRuleTest.php +++ b/tests/Validation/ValidationUniqueRuleTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Validation; -use Hyperf\Database\ConnectionResolverInterface; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Testbench\TestCase; @@ -13,6 +13,7 @@ use Hypervel\Validation\DatabasePresenceVerifier; use Hypervel\Validation\Rules\Unique; use Hypervel\Validation\Validator; +use UnitEnum; /** * @internal @@ -254,5 +255,5 @@ public function __construct($bar, $baz) class EloquentModelWithConnection extends EloquentModelStub { - protected ?string $connection = 'mysql'; + protected UnitEnum|string|null $connection = 'mysql'; } diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 19f326f34..055b1118a 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -10,25 +10,25 @@ use DateTime; use DateTimeImmutable; use Egulias\EmailValidator\Validation\NoRFCWarningsValidation; -use Hyperf\Database\Model\Model; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Guard; use Hypervel\Container\Container; use Hypervel\Context\ApplicationContext; -use Hypervel\Hashing\Contracts\Hasher; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\Guard; +use Hypervel\Contracts\Hashing\Hasher; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; +use Hypervel\Contracts\Validation\DataAwareRule; +use Hypervel\Contracts\Validation\ImplicitRule; +use Hypervel\Contracts\Validation\Rule; +use Hypervel\Contracts\Validation\Validator as ValidatorContract; +use Hypervel\Contracts\Validation\ValidatorAwareRule; +use Hypervel\Database\Eloquent\Model; use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Support\Exceptions\MathException; use Hypervel\Support\Stringable; use Hypervel\Translation\ArrayLoader; -use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Hypervel\Translation\Translator; -use Hypervel\Validation\Contracts\DataAwareRule; -use Hypervel\Validation\Contracts\ImplicitRule; -use Hypervel\Validation\Contracts\Rule; -use Hypervel\Validation\Contracts\Validator as ValidatorContract; -use Hypervel\Validation\Contracts\ValidatorAwareRule; use Hypervel\Validation\DatabasePresenceVerifierInterface; use Hypervel\Validation\Rule as ValidationRule; use Hypervel\Validation\Rules\Exists; @@ -47,6 +47,7 @@ use RuntimeException; use SplFileInfo; use stdClass; +use UnitEnum; /** * @internal @@ -59,7 +60,6 @@ protected function tearDown(): void parent::tearDown(); Carbon::setTestNow(null); - m::close(); } public function testNestedErrorMessagesAreRetrievedFromLocalArray() @@ -1138,8 +1138,8 @@ public function testValidateCurrentPassword() $hasher = m::mock(Hasher::class); $container = m::mock(ContainerInterface::class); - $container->shouldReceive('get')->with(\Hypervel\Auth\Contracts\Factory::class)->andReturn($auth); - $container->shouldReceive('get')->with(\Hypervel\Hashing\Contracts\Hasher::class)->andReturn($hasher); + $container->shouldReceive('get')->with(\Hypervel\Contracts\Auth\Factory::class)->andReturn($auth); + $container->shouldReceive('get')->with(\Hypervel\Contracts\Hashing\Hasher::class)->andReturn($hasher); $trans = $this->getTranslator(); $trans->shouldReceive('get')->andReturnArg(0); @@ -1162,8 +1162,8 @@ public function testValidateCurrentPassword() $hasher->shouldReceive('check')->andReturn(false); $container = m::mock(ContainerInterface::class); - $container->shouldReceive('get')->with(\Hypervel\Auth\Contracts\Factory::class)->andReturn($auth); - $container->shouldReceive('get')->with(\Hypervel\Hashing\Contracts\Hasher::class)->andReturn($hasher); + $container->shouldReceive('get')->with(\Hypervel\Contracts\Auth\Factory::class)->andReturn($auth); + $container->shouldReceive('get')->with(\Hypervel\Contracts\Hashing\Hasher::class)->andReturn($hasher); $trans = $this->getTranslator(); $trans->shouldReceive('get')->andReturnArg(0); @@ -1186,8 +1186,8 @@ public function testValidateCurrentPassword() $hasher->shouldReceive('check')->andReturn(true); $container = m::mock(ContainerInterface::class); - $container->shouldReceive('get')->with(\Hypervel\Auth\Contracts\Factory::class)->andReturn($auth); - $container->shouldReceive('get')->with(\Hypervel\Hashing\Contracts\Hasher::class)->andReturn($hasher); + $container->shouldReceive('get')->with(\Hypervel\Contracts\Auth\Factory::class)->andReturn($auth); + $container->shouldReceive('get')->with(\Hypervel\Contracts\Hashing\Hasher::class)->andReturn($hasher); $trans = $this->getTranslator(); $trans->shouldReceive('get')->andReturnArg(0); @@ -1210,8 +1210,8 @@ public function testValidateCurrentPassword() $hasher->shouldReceive('check')->andReturn(true); $container = m::mock(ContainerInterface::class); - $container->shouldReceive('get')->with(\Hypervel\Auth\Contracts\Factory::class)->andReturn($auth); - $container->shouldReceive('get')->with(\Hypervel\Hashing\Contracts\Hasher::class)->andReturn($hasher); + $container->shouldReceive('get')->with(\Hypervel\Contracts\Auth\Factory::class)->andReturn($auth); + $container->shouldReceive('get')->with(\Hypervel\Contracts\Hashing\Hasher::class)->andReturn($hasher); $trans = $this->getTranslator(); $trans->shouldReceive('get')->andReturnArg(0); @@ -7860,15 +7860,15 @@ public function testParsingTablesFromModels() $v = new Validator($trans, [], []); $implicit_no_connection = $v->parseTable(ImplicitTableModel::class); - $this->assertSame('default', $implicit_no_connection[0]); + $this->assertNull($implicit_no_connection[0]); $this->assertSame('implicit_table_models', $implicit_no_connection[1]); $explicit_no_connection = $v->parseTable(ExplicitTableModel::class); - $this->assertSame('default', $explicit_no_connection[0]); + $this->assertNull($explicit_no_connection[0]); $this->assertSame('explicits', $explicit_no_connection[1]); $explicit_model_with_prefix = $v->parseTable(ExplicitPrefixedTableModel::class); - $this->assertSame('default', $explicit_model_with_prefix[0]); + $this->assertNull($explicit_model_with_prefix[0]); $this->assertSame('prefix.explicits', $explicit_model_with_prefix[1]); $explicit_table_with_connection_prefix = $v->parseTable('connection.table'); @@ -9774,7 +9774,7 @@ class ExplicitTableAndConnectionModel extends Model { protected ?string $table = 'explicits'; - protected ?string $connection = 'connection'; + protected UnitEnum|string|null $connection = 'connection'; protected array $guarded = []; diff --git a/tests/Validation/fixtures/Values.php b/tests/Validation/fixtures/Values.php index de0c88dd4..5162290de 100644 --- a/tests/Validation/fixtures/Values.php +++ b/tests/Validation/fixtures/Values.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Validation\fixtures; -use Hypervel\Support\Contracts\Arrayable; +use Hypervel\Contracts\Support\Arrayable; class Values implements Arrayable { diff --git a/tests/Validation/migrations/2025_05_20_000000_create_table_table.php b/tests/Validation/migrations/2025_05_20_000000_create_table_table.php index 5f175e0ac..5444c1e45 100644 --- a/tests/Validation/migrations/2025_05_20_000000_create_table_table.php +++ b/tests/Validation/migrations/2025_05_20_000000_create_table_table.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; return new class extends Migration { diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 000000000..b041b853c --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,22 @@ +load(); +} diff --git a/types/Autoload.php b/types/Autoload.php index c5eaeba24..3d4a82fb8 100644 --- a/types/Autoload.php +++ b/types/Autoload.php @@ -2,13 +2,36 @@ declare(strict_types=1); +use Hypervel\Database\Eloquent\Factories\Factory; +use Hypervel\Database\Eloquent\Factories\HasFactory; +use Hypervel\Database\Eloquent\MassPrunable; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Foundation\Auth\User as Authenticatable; +use Hypervel\Notifications\HasDatabaseNotifications; class User extends Authenticatable { + use HasDatabaseNotifications; + + /** @use HasFactory */ + use HasFactory; + + use MassPrunable; use SoftDeletes; + + protected static string $factory = UserFactory::class; +} + +/** @extends Factory */ +class UserFactory extends Factory +{ + protected ?string $model = User::class; + + public function definition(): array + { + return []; + } } class Post extends Model diff --git a/types/Collections/helpers.php b/types/Collections/helpers.php new file mode 100644 index 000000000..01ea45671 --- /dev/null +++ b/types/Collections/helpers.php @@ -0,0 +1,13 @@ + 42)); +assertType('42', value(function ($foo) { + assertType('true', $foo); + + return 42; +}, true)); diff --git a/types/Database/Eloquent/Builder.php b/types/Database/Eloquent/Builder.php index c9f9cd58e..caba0ad2e 100644 --- a/types/Database/Eloquent/Builder.php +++ b/types/Database/Eloquent/Builder.php @@ -5,10 +5,12 @@ namespace Hypervel\Types\Builder; use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\HasBuilder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\HasMany; use Hypervel\Database\Eloquent\Relations\MorphTo; +use Hypervel\Database\Query\Builder as QueryBuilder; use function PHPStan\Testing\assertType; @@ -18,16 +20,23 @@ function test( User $user, Post $post, ChildPost $childPost, - Comment $comment + Comment $comment, + QueryBuilder $queryBuilder ): void { assertType('Hypervel\Database\Eloquent\Builder', $query->where('id', 1)); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhere('name', 'John')); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereNot('status', 'active')); assertType('Hypervel\Database\Eloquent\Builder', $query->with('relation')); assertType('Hypervel\Database\Eloquent\Builder', $query->with(['relation' => ['foo' => fn ($q) => $q]])); assertType('Hypervel\Database\Eloquent\Builder', $query->with(['relation' => function ($query) { // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); }])); assertType('Hypervel\Database\Eloquent\Builder', $query->without('relation')); + assertType('Hypervel\Database\Eloquent\Builder', $query->withOnly(['relation'])); + assertType('Hypervel\Database\Eloquent\Builder', $query->withOnly(['relation' => ['foo' => fn ($q) => $q]])); + assertType('Hypervel\Database\Eloquent\Builder', $query->withOnly(['relation' => function ($query) { + // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); + }])); assertType('array', $query->getModels()); assertType('array', $query->eagerLoadRelations([])); assertType('Hypervel\Database\Eloquent\Collection', $query->get()); @@ -48,21 +57,23 @@ function test( assertType('Hypervel\Types\Builder\User', $query->firstOrNew(['id' => 1])); assertType('Hypervel\Types\Builder\User', $query->findOrNew(1)); assertType('Hypervel\Types\Builder\User', $query->firstOrCreate(['id' => 1])); - assertType('Hypervel\Types\Builder\User', $query->createOrfirst(['id' => 1])); assertType('Hypervel\Types\Builder\User', $query->create(['name' => 'John'])); assertType('Hypervel\Types\Builder\User', $query->forceCreate(['name' => 'John'])); + assertType('Hypervel\Types\Builder\User', $query->forceCreateQuietly(['name' => 'John'])); assertType('Hypervel\Types\Builder\User', $query->getModel()); assertType('Hypervel\Types\Builder\User', $query->make(['name' => 'John'])); assertType('Hypervel\Types\Builder\User', $query->forceCreate(['name' => 'John'])); assertType('Hypervel\Types\Builder\User', $query->updateOrCreate(['id' => 1], ['name' => 'John'])); assertType('Hypervel\Types\Builder\User', $query->firstOrFail()); + assertType('Hypervel\Types\Builder\User', $query->findSole(1)); assertType('Hypervel\Types\Builder\User', $query->sole()); assertType('Hypervel\Support\LazyCollection', $query->cursor()); + assertType('Hypervel\Support\LazyCollection', $query->cursor()); assertType('Hypervel\Support\LazyCollection', $query->lazy()); assertType('Hypervel\Support\LazyCollection', $query->lazyById()); assertType('Hypervel\Support\LazyCollection', $query->lazyByIdDesc()); assertType('Hypervel\Support\Collection<(int|string), mixed>', $query->pluck('foo')); - assertType('Hypervel\Database\Eloquent\Relations\Contracts\Relation', $query->getRelation('foo')); + assertType('Hypervel\Database\Eloquent\Relations\Relation', $query->getRelation('foo')); assertType('Hypervel\Database\Eloquent\Builder', $query->setModel(new Post())); assertType('Hypervel\Database\Eloquent\Builder', $query->has('foo', callback: function ($query) { @@ -80,7 +91,7 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->withWhereHas('posts', function ($query) { - assertType('Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Contracts\Relation<*, *, *>', $query); + assertType('Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereHas($user->posts(), function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); @@ -117,24 +128,48 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query); assertType('string', $type); })); - assertType('Hypervel\Database\Eloquent\Builder', $query->whereRelation($user->posts(), 'id', 1)); - assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereRelation($user->posts(), 'id', 1)); - assertType('Hypervel\Database\Eloquent\Builder', $query->whereMorphRelation($post->taggable(), 'taggable', 'id', 1)); - assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereMorphRelation($post->taggable(), 'taggable', 'id', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereRelation($user->posts(), function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereRelation($user->posts(), function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereDoesntHaveRelation($user->posts(), function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereDoesntHaveRelation($user->posts(), function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereMorphRelation($post->taggable(), 'taggable', function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereMorphRelation($post->taggable(), 'taggable', function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereMorphDoesntHaveRelation($post->taggable(), 'taggable', function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereMorphDoesntHaveRelation($post->taggable(), 'taggable', function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query); + })); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereMorphedTo($post->taggable(), new Post())); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereNotMorphedTo($post->taggable(), new Post())); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereMorphedTo($post->taggable(), new Post())); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereNotMorphedTo($post->taggable(), new Post())); $query->chunk(1, function ($users, $page) { - assertType('Hypervel\Database\Eloquent\Collection', $users); + assertType('Hypervel\Support\Collection', $users); assertType('int', $page); }); $query->chunkById(1, function ($users, $page) { - assertType('Hypervel\Database\Eloquent\Collection', $users); + assertType('Hypervel\Support\Collection', $users); assertType('int', $page); }); $query->chunkMap(function ($users) { assertType('Hypervel\Types\Builder\User', $users); }); $query->chunkByIdDesc(1, function ($users, $page) { - assertType('Hypervel\Database\Eloquent\Collection', $users); + assertType('Hypervel\Support\Collection', $users); assertType('int', $page); }); $query->each(function ($users, $page) { @@ -146,44 +181,55 @@ function test( assertType('int', $page); }); - assertType('Hypervel\Database\Eloquent\Builder', Post::query()); - assertType('Hypervel\Database\Eloquent\Builder', Post::on()); - assertType('Hypervel\Database\Eloquent\Builder', Post::onWriteConnection()); - assertType('Hypervel\Database\Eloquent\Builder', Post::with([])); - assertType('Hypervel\Database\Eloquent\Builder', $post->newQuery()); - assertType('Hypervel\Database\Eloquent\Builder', $post->newModelQuery()); - assertType('Hypervel\Database\Eloquent\Builder', $post->newQueryWithoutRelationships()); - assertType('Hypervel\Database\Eloquent\Builder', $post->newQueryWithoutScopes()); - assertType('Hypervel\Database\Eloquent\Builder', $post->newQueryWithoutScope('foo')); - assertType('Hypervel\Database\Eloquent\Builder', $post->newQueryForRestoration(1)); - assertType('Hypervel\Database\Eloquent\Builder', $post->newQuery()->where('foo', 'bar')); + assertType('Hypervel\Types\Builder\CommonBuilder', Post::query()); + assertType('Hypervel\Types\Builder\CommonBuilder', Post::on()); + assertType('Hypervel\Types\Builder\CommonBuilder', Post::onWriteConnection()); + assertType('Hypervel\Types\Builder\CommonBuilder', Post::with([])); + assertType('Hypervel\Types\Builder\CommonBuilder', $post->newQuery()); + assertType('Hypervel\Types\Builder\CommonBuilder', $post->newEloquentBuilder($queryBuilder)); + assertType('Hypervel\Types\Builder\CommonBuilder', $post->newModelQuery()); + assertType('Hypervel\Types\Builder\CommonBuilder', $post->newQueryWithoutRelationships()); + assertType('Hypervel\Types\Builder\CommonBuilder', $post->newQueryWithoutScopes()); + assertType('Hypervel\Types\Builder\CommonBuilder', $post->newQueryWithoutScope('foo')); + assertType('Hypervel\Types\Builder\CommonBuilder', $post->newQueryForRestoration(1)); + assertType('Hypervel\Types\Builder\CommonBuilder', $post->newQuery()->where('foo', 'bar')); + assertType('Hypervel\Types\Builder\CommonBuilder', $post->newQuery()->foo()); assertType('Hypervel\Types\Builder\Post', $post->newQuery()->create(['name' => 'John'])); - assertType('Hypervel\Database\Eloquent\Builder', ChildPost::query()); - assertType('Hypervel\Database\Eloquent\Builder', ChildPost::on()); - assertType('Hypervel\Database\Eloquent\Builder', ChildPost::onWriteConnection()); - assertType('Hypervel\Database\Eloquent\Builder', ChildPost::with([])); - assertType('Hypervel\Database\Eloquent\Builder', $childPost->newQuery()); - assertType('Hypervel\Database\Eloquent\Builder', $childPost->newModelQuery()); - assertType('Hypervel\Database\Eloquent\Builder', $childPost->newQueryWithoutRelationships()); - assertType('Hypervel\Database\Eloquent\Builder', $childPost->newQueryWithoutScopes()); - assertType('Hypervel\Database\Eloquent\Builder', $childPost->newQueryWithoutScope('foo')); - assertType('Hypervel\Database\Eloquent\Builder', $childPost->newQueryForRestoration(1)); - assertType('Hypervel\Database\Eloquent\Builder', $childPost->newQuery()->where('foo', 'bar')); + assertType('Hypervel\Types\Builder\CommonBuilder', ChildPost::query()); + assertType('Hypervel\Types\Builder\CommonBuilder', ChildPost::on()); + assertType('Hypervel\Types\Builder\CommonBuilder', ChildPost::onWriteConnection()); + assertType('Hypervel\Types\Builder\CommonBuilder', ChildPost::with([])); + assertType('Hypervel\Types\Builder\CommonBuilder', $childPost->newQuery()); + assertType('Hypervel\Types\Builder\CommonBuilder', $childPost->newEloquentBuilder($queryBuilder)); + assertType('Hypervel\Types\Builder\CommonBuilder', $childPost->newModelQuery()); + assertType('Hypervel\Types\Builder\CommonBuilder', $childPost->newQueryWithoutRelationships()); + assertType('Hypervel\Types\Builder\CommonBuilder', $childPost->newQueryWithoutScopes()); + assertType('Hypervel\Types\Builder\CommonBuilder', $childPost->newQueryWithoutScope('foo')); + assertType('Hypervel\Types\Builder\CommonBuilder', $childPost->newQueryForRestoration(1)); + assertType('Hypervel\Types\Builder\CommonBuilder', $childPost->newQuery()->where('foo', 'bar')); + assertType('Hypervel\Types\Builder\CommonBuilder', $childPost->newQuery()->foo()); assertType('Hypervel\Types\Builder\ChildPost', $childPost->newQuery()->create(['name' => 'John'])); - assertType('Hypervel\Database\Eloquent\Builder', Comment::query()); - assertType('Hypervel\Database\Eloquent\Builder', Comment::on()); - assertType('Hypervel\Database\Eloquent\Builder', Comment::onWriteConnection()); - assertType('Hypervel\Database\Eloquent\Builder', Comment::with([])); - assertType('Hypervel\Database\Eloquent\Builder', $comment->newQuery()); - assertType('Hypervel\Database\Eloquent\Builder', $comment->newModelQuery()); - assertType('Hypervel\Database\Eloquent\Builder', $comment->newQueryWithoutRelationships()); - assertType('Hypervel\Database\Eloquent\Builder', $comment->newQueryWithoutScopes()); - assertType('Hypervel\Database\Eloquent\Builder', $comment->newQueryWithoutScope('foo')); - assertType('Hypervel\Database\Eloquent\Builder', $comment->newQueryForRestoration(1)); - assertType('Hypervel\Database\Eloquent\Builder', $comment->newQuery()->where('foo', 'bar')); + assertType('Hypervel\Types\Builder\CommentBuilder', Comment::query()); + assertType('Hypervel\Types\Builder\CommentBuilder', Comment::on()); + assertType('Hypervel\Types\Builder\CommentBuilder', Comment::onWriteConnection()); + assertType('Hypervel\Types\Builder\CommentBuilder', Comment::with([])); + assertType('Hypervel\Types\Builder\CommentBuilder', $comment->newQuery()); + assertType('Hypervel\Types\Builder\CommentBuilder', $comment->newEloquentBuilder($queryBuilder)); + assertType('Hypervel\Types\Builder\CommentBuilder', $comment->newModelQuery()); + assertType('Hypervel\Types\Builder\CommentBuilder', $comment->newQueryWithoutRelationships()); + assertType('Hypervel\Types\Builder\CommentBuilder', $comment->newQueryWithoutScopes()); + assertType('Hypervel\Types\Builder\CommentBuilder', $comment->newQueryWithoutScope('foo')); + assertType('Hypervel\Types\Builder\CommentBuilder', $comment->newQueryForRestoration(1)); + assertType('Hypervel\Types\Builder\CommentBuilder', $comment->newQuery()->where('foo', 'bar')); + assertType('Hypervel\Types\Builder\CommentBuilder', $comment->newQuery()->foo()); assertType('Hypervel\Types\Builder\Comment', $comment->newQuery()->create(['name' => 'John'])); + assertType('Hypervel\Database\Eloquent\Builder', $query->pipe(function () { + })); + assertType('Hypervel\Database\Eloquent\Builder', $query->pipe(fn () => null)); + assertType('Hypervel\Database\Eloquent\Builder', $query->pipe(fn ($query) => $query)); + assertType('5', $query->pipe(fn ($query) => 5)); } class User extends Model @@ -195,8 +241,13 @@ public function posts(): HasMany } } -class Post extends \Hypervel\Database\Eloquent\Model +class Post extends Model { + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = CommonBuilder::class; + /** @return BelongsTo */ public function user(): BelongsTo { @@ -216,6 +267,10 @@ class ChildPost extends Post class Comment extends Model { + /** @use HasBuilder */ + use HasBuilder; + + protected static string $builder = CommentBuilder::class; } /** diff --git a/types/Database/Eloquent/Casts/Castable.php b/types/Database/Eloquent/Casts/Castable.php new file mode 100644 index 000000000..d264d4774 --- /dev/null +++ b/types/Database/Eloquent/Casts/Castable.php @@ -0,0 +1,40 @@ +, iterable>', + \Hypervel\Database\Eloquent\Casts\AsArrayObject::castUsing([]), +); + +assertType( + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Hypervel\Database\Eloquent\Casts\AsCollection::castUsing([]), +); + +assertType( + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Hypervel\Database\Eloquent\Casts\AsEncryptedArrayObject::castUsing([]), +); + +assertType( + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Hypervel\Database\Eloquent\Casts\AsEncryptedCollection::castUsing([]), +); + +assertType( + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Hypervel\Database\Eloquent\Casts\AsEnumArrayObject::castUsing([\UserType::class]), +); + +assertType( + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Hypervel\Database\Eloquent\Casts\AsEnumCollection::castUsing([\UserType::class]), +); + +assertType( + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes', + \Hypervel\Database\Eloquent\Casts\AsStringable::castUsing([]), +); diff --git a/types/Database/Eloquent/Casts/CastsAttributes.php b/types/Database/Eloquent/Casts/CastsAttributes.php new file mode 100644 index 000000000..c7bcaaf0d --- /dev/null +++ b/types/Database/Eloquent/Casts/CastsAttributes.php @@ -0,0 +1,13 @@ + $cast */ +assertType('Hypervel\Support\Stringable|null', $cast->get($user, 'email', 'taylor@laravel.com', $user->getAttributes())); + +$cast->set($user, 'email', 'taylor@laravel.com', $user->getAttributes()); // This works. +$cast->set($user, 'email', \Hypervel\Support\Str::of('taylor@laravel.com'), $user->getAttributes()); // This also works! +$cast->set($user, 'email', null, $user->getAttributes()); // Also valid. diff --git a/types/Database/Eloquent/Collection.php b/types/Database/Eloquent/Collection.php index c5ff31df1..6bc118937 100644 --- a/types/Database/Eloquent/Collection.php +++ b/types/Database/Eloquent/Collection.php @@ -61,6 +61,13 @@ // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); }], 'string')); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists('string')); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string'])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => ['foo' => fn ($q) => $q]])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => function ($query) { + // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); +}])); + assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMissing('string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMissing(['string'])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMissing(['string' => ['foo' => fn ($q) => $q]])); @@ -93,7 +100,7 @@ assertType('Hypervel\Database\Eloquent\Collection', $collection->merge([new User()])); assertType( - 'Hypervel\Database\Eloquent\Collection', + 'Hypervel\Support\Collection', $collection->map(function ($user, $int) { assertType('User', $user); assertType('int', $int); @@ -101,13 +108,20 @@ return new User(); }) ); + assertType( - 'Hypervel\Support\Collection', - $collection->map(function ($user, $int) { + 'Hypervel\Support\Collection', + $collection->mapWithKeys(function ($user, $int) { assertType('User', $user); assertType('int', $int); - return 'string'; + return [new User()]; + }) +); +assertType( + 'Hypervel\Support\Collection', + $collection->mapWithKeys(function ($user, $int) { + return ['string' => new User()]; }) ); @@ -178,3 +192,9 @@ assertType('Hypervel\Support\Collection', $collection->pad(2, 0)); assertType('Hypervel\Support\Collection', $collection->pad(2, 'string')); + +assertType('array', $collection->getQueueableIds()); + +assertType('array', $collection->getQueueableRelations()); + +assertType('Hypervel\Database\Eloquent\Builder', $collection->toQuery()); diff --git a/types/Database/Eloquent/Factories/Factory.php b/types/Database/Eloquent/Factories/Factory.php new file mode 100644 index 000000000..159e0a57a --- /dev/null +++ b/types/Database/Eloquent/Factories/Factory.php @@ -0,0 +1,196 @@ + */ +class UserFactory extends Factory +{ + protected ?string $model = User::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} + +/** @extends Hypervel\Database\Eloquent\Factories\Factory */ +class PostFactory extends Factory +{ + protected ?string $model = Post::class; + + /** @return array */ + public function definition(): array + { + return []; + } +} + +assertType('UserFactory', $factory = UserFactory::new()); +assertType('UserFactory', UserFactory::new(['string' => 'string'])); +assertType('UserFactory', UserFactory::new(function ($attributes) { + assertType('array', $attributes); + + return ['string' => 'string']; +})); + +assertType('array', $factory->definition()); + +assertType('UserFactory', $factory::times(10)); + +assertType('UserFactory', $factory->configure()); + +assertType('array', $factory->raw()); +assertType('array', $factory->raw(['string' => 'string'])); +assertType('array', $factory->raw(function ($attributes) { + assertType('array', $attributes); + + return ['string' => 'string']; +})); + +assertType('User', $factory->createOne()); +assertType('User', $factory->createOne(['string' => 'string'])); +assertType('User', $factory->createOne(function ($attributes) { + assertType('array', $attributes); + + return ['string' => 'string']; +})); + +assertType('User', $factory->createOneQuietly()); +assertType('User', $factory->createOneQuietly(['string' => 'string'])); +assertType('User', $factory->createOneQuietly(function ($attributes) { + assertType('array', $attributes); + + return ['string' => 'string']; +})); + +assertType('Hypervel\Database\Eloquent\Collection', $factory->createMany([['string' => 'string']])); +assertType('Hypervel\Database\Eloquent\Collection', $factory->createMany(3)); +assertType('Hypervel\Database\Eloquent\Collection', $factory->createMany()); + +assertType('Hypervel\Database\Eloquent\Collection', $factory->createManyQuietly([['string' => 'string']])); +assertType('Hypervel\Database\Eloquent\Collection', $factory->createManyQuietly(3)); +assertType('Hypervel\Database\Eloquent\Collection', $factory->createManyQuietly()); + +assertType('Hypervel\Database\Eloquent\Collection|User', $factory->create()); +assertType('Hypervel\Database\Eloquent\Collection|User', $factory->create(['string' => 'string'])); +assertType('Hypervel\Database\Eloquent\Collection|User', $factory->create(function ($attributes) { + assertType('array', $attributes); + + return ['string' => 'string']; +})); + +assertType('Hypervel\Database\Eloquent\Collection|User', $factory->createQuietly()); +assertType('Hypervel\Database\Eloquent\Collection|User', $factory->createQuietly(['string' => 'string'])); +assertType('Hypervel\Database\Eloquent\Collection|User', $factory->createQuietly(function ($attributes) { + assertType('array', $attributes); + + return ['string' => 'string']; +})); + +assertType('Closure(): (Hypervel\Database\Eloquent\Collection|User)', $factory->lazy()); +assertType('Closure(): (Hypervel\Database\Eloquent\Collection|User)', $factory->lazy(['string' => 'string'])); + +assertType('User', $factory->makeOne()); +assertType('User', $factory->makeOne(['string' => 'string'])); +assertType('User', $factory->makeOne(function ($attributes) { + assertType('array', $attributes); + + return ['string' => 'string']; +})); + +assertType('Hypervel\Database\Eloquent\Collection|User', $factory->make()); +assertType('Hypervel\Database\Eloquent\Collection|User', $factory->make(['string' => 'string'])); +assertType('Hypervel\Database\Eloquent\Collection|User', $factory->make(function ($attributes) { + assertType('array', $attributes); + + return ['string' => 'string']; +})); + +assertType('UserFactory', $factory->state(['string' => 'string'])); +assertType('UserFactory', $factory->state(function ($attributes) { + assertType('array', $attributes); + + return ['string' => 'string']; +})); +assertType('UserFactory', $factory->state(function ($attributes, $model) { + assertType('array', $attributes); + assertType('Hypervel\Database\Eloquent\Model|null', $model); + + return ['string' => 'string']; +})); + +assertType('UserFactory', $factory->sequence([['string' => 'string']])); + +assertType('UserFactory', $factory->has($factory)); + +assertType('UserFactory', $factory->hasAttached($factory, ['string' => 'string'])); +assertType('UserFactory', $factory->hasAttached($factory->createOne(), ['string' => 'string'])); +assertType('UserFactory', $factory->hasAttached($factory->createOne(), function () { + return ['string' => 'string']; +})); + +assertType('UserFactory', $factory->for($factory)); +assertType('UserFactory', $factory->for($factory->createOne())); + +assertType('UserFactory', $factory->afterMaking(function ($user) { + assertType('User', $user); + + return 'string'; +})); + +assertType('UserFactory', $factory->afterCreating(function ($user) { + assertType('User', $user); + + return 'string'; +})); + +assertType('UserFactory', $factory->count(10)); + +assertType('UserFactory', $factory->connection('string')); + +assertType('User', $factory->newModel()); +assertType('User', $factory->newModel(['string' => 'string'])); + +assertType('class-string', $factory->modelName()); + +assertType('Post|null', $factory->getRandomRecycledModel(Post::class)); + +Factory::guessModelNamesUsing(function (Factory $factory) { + return match (true) { + $factory instanceof UserFactory => User::class, + default => throw new LogicException('Unknown factory'), + }; +}); + +$factory->useNamespace('string'); + +assertType('Hypervel\Database\Eloquent\Factories\Factory', $factory::factoryForModel(User::class)); +assertType('class-string>', $factory->resolveFactoryName(User::class)); + +Factory::guessFactoryNamesUsing(function (string $modelName) { + return match ($modelName) { + User::class => UserFactory::class, + default => throw new LogicException('Unknown factory'), + }; +}); + +UserFactory::new()->has( + PostFactory::new() + ->state(function ($attributes, $user) { + assertType('array', $attributes); + assertType('Hypervel\Database\Eloquent\Model|null', $user); + + return ['user_id' => $user?->getKey()]; + }) + ->prependState(function ($attributes, $user) { + assertType('array', $attributes); + assertType('Hypervel\Database\Eloquent\Model|null', $user); + + return ['user_id' => $user?->getKey()]; + }), +); diff --git a/types/Database/Eloquent/Model.php b/types/Database/Eloquent/Model.php index 2b376eda2..f7c897ce6 100644 --- a/types/Database/Eloquent/Model.php +++ b/types/Database/Eloquent/Model.php @@ -4,7 +4,9 @@ namespace Hypervel\Types\Model; +use Hypervel\Database\Eloquent\Attributes\CollectedBy; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\HasCollection; use Hypervel\Database\Eloquent\Model; use User; @@ -12,23 +14,49 @@ function test(User $user, Post $post, Comment $comment, Article $article): void { + assertType('UserFactory', User::factory(function ($attributes, $model) { + assertType('array', $attributes); + assertType('User|null', $model); + + return ['string' => 'string']; + })); + assertType('UserFactory', User::factory(42, function ($attributes, $model) { + assertType('array', $attributes); + assertType('User|null', $model); + + return ['string' => 'string']; + })); + + User::addGlobalScope('ancient', function ($builder) { + assertType('Hypervel\Database\Eloquent\Builder', $builder); + + $builder->where('created_at', '<', now()->subYears(2000)); + }); + assertType('Hypervel\Database\Eloquent\Builder', User::query()); assertType('Hypervel\Database\Eloquent\Builder', $user->newQuery()); assertType('Hypervel\Database\Eloquent\Builder', $user->withTrashed()); assertType('Hypervel\Database\Eloquent\Builder', $user->onlyTrashed()); assertType('Hypervel\Database\Eloquent\Builder', $user->withoutTrashed()); + assertType('Hypervel\Database\Eloquent\Builder', $user->prunable()); + assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $user->notifications()); + assertType('Hypervel\Database\Query\Builder', $user->unreadNotifications()); assertType('Hypervel\Database\Eloquent\Collection<(int|string), User>', $user->newCollection([new User()])); - assertType('Hypervel\Types\Model\Comments', $comment->newCollection([new Comment()])); - assertType('Hypervel\Database\Eloquent\Collection<(int|string), Hypervel\Types\Model\Post>', $post->newCollection(['foo' => new Post()])); - assertType('Hypervel\Database\Eloquent\Collection<(int|string), Hypervel\Types\Model\Article>', $article->newCollection([new Article()])); + assertType('Hypervel\Types\Model\Posts<(int|string), Hypervel\Types\Model\Post>', $post->newCollection(['foo' => new Post()])); + assertType('Hypervel\Types\Model\Articles<(int|string), Hypervel\Types\Model\Article>', $article->newCollection([new Article()])); assertType('Hypervel\Types\Model\Comments', $comment->newCollection([new Comment()])); - assertType('bool|null', $user->restore()); + assertType('bool', $user->restore()); + assertType('User', $user->restoreOrCreate()); + assertType('User', $user->createOrRestore()); } class Post extends Model { + /** @use HasCollection> */ + use HasCollection; + protected static string $collectionClass = Posts::class; } @@ -43,6 +71,9 @@ class Posts extends Collection final class Comment extends Model { + /** @use HasCollection */ + use HasCollection; + /** @param array $models */ public function newCollection(array $models = []): Comments { @@ -55,8 +86,11 @@ final class Comments extends Collection { } +#[CollectedBy(Articles::class)] class Article extends Model { + /** @use HasCollection> */ + use HasCollection; } /** diff --git a/types/Database/Eloquent/ModelNotFoundException.php b/types/Database/Eloquent/ModelNotFoundException.php index 4b6535682..d786c64eb 100644 --- a/types/Database/Eloquent/ModelNotFoundException.php +++ b/types/Database/Eloquent/ModelNotFoundException.php @@ -10,7 +10,7 @@ $exception = new ModelNotFoundException(); assertType('array', $exception->getIds()); -assertType('class-string|null', $exception->getModel()); +assertType('class-string', $exception->getModel()); $exception->setModel(User::class, 1); $exception->setModel(User::class, [1]); diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index 81ebfd0b7..5ef27c75d 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -34,10 +34,14 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->getResults()); + assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->makeMany([])); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->createMany([])); + assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->createManyQuietly([])); + assertType('Hypervel\Database\Eloquent\Relations\HasOne', $user->latestPost()); assertType('Hypervel\Types\Relations\Post', $user->posts()->make()); assertType('Hypervel\Types\Relations\Post', $user->posts()->create()); assertType('Hypervel\Types\Relations\Post|false', $user->posts()->save(new Post())); + assertType('Hypervel\Types\Relations\Post|false', $user->posts()->saveQuietly(new Post())); assertType("Hypervel\\Database\\Eloquent\\Relations\\BelongsToMany", $user->roles()); assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->getResults()); @@ -45,31 +49,56 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->findMany([1, 2, 3])); assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->findOrNew([1])); assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->findOrFail([1])); + assertType('42|Hypervel\Database\Eloquent\Collection', $user->roles()->findOr([1], fn () => 42)); + assertType('42|Hypervel\Database\Eloquent\Collection', $user->roles()->findOr([1], callback: fn () => 42)); assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->findOrNew(1)); assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->findOrFail(1)); assertType('(Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', $user->roles()->find(1)); + assertType('42|(Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $user->roles()->findOr(1, fn () => 42)); + assertType('42|(Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $user->roles()->findOr(1, callback: fn () => 42)); assertType('(Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', $user->roles()->first()); + assertType('42|(Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $user->roles()->firstOr(fn () => 42)); + assertType('42|(Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})', $user->roles()->firstOr(callback: fn () => 42)); + assertType('(Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot})|null', $user->roles()->firstWhere('foo')); assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->firstOrNew()); assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->firstOrFail()); assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->firstOrCreate()); assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->create()); + assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->createOrFirst()); assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->updateOrCreate([])); assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->save(new Role())); + assertType('Hypervel\Types\Relations\Role&object{pivot: Hypervel\Database\Eloquent\Relations\Pivot}', $user->roles()->saveQuietly(new Role())); $roles = $user->roles()->getResults(); - assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->saveMany($roles)); - assertType('array', $user->roles()->saveMany($roles->all())); - assertType('array', $user->roles()->createMany($roles->all())); + assertType('iterable<(int|string), Hypervel\Types\Relations\Role>', $user->roles()->saveMany($roles)); + assertType('iterable<(int|string), Hypervel\Types\Relations\Role>', $user->roles()->saveMany($roles->all())); + assertType('iterable<(int|string), Hypervel\Types\Relations\Role>', $user->roles()->saveManyQuietly($roles)); + assertType('iterable<(int|string), Hypervel\Types\Relations\Role>', $user->roles()->saveManyQuietly($roles->all())); + assertType('array', $user->roles()->createMany($roles)); assertType('array{attached: array, detached: array, updated: array}', $user->roles()->sync($roles)); assertType('array{attached: array, detached: array, updated: array}', $user->roles()->syncWithoutDetaching($roles)); + assertType('array{attached: array, detached: array, updated: array}', $user->roles()->syncWithPivotValues($roles, [])); + assertType('Hypervel\Support\LazyCollection', $user->roles()->lazy()); + assertType('Hypervel\Support\LazyCollection', $user->roles()->lazyById()); + assertType('Hypervel\Support\LazyCollection', $user->roles()->cursor()); assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $user->car()); assertType('Hypervel\Types\Relations\Car|null', $user->car()->getResults()); assertType('Hypervel\Database\Eloquent\Collection', $user->car()->find([1])); + assertType('42|Hypervel\Database\Eloquent\Collection', $user->car()->findOr([1], fn () => 42)); + assertType('42|Hypervel\Database\Eloquent\Collection', $user->car()->findOr([1], callback: fn () => 42)); assertType('Hypervel\Types\Relations\Car|null', $user->car()->find(1)); + assertType('42|Hypervel\Types\Relations\Car', $user->car()->findOr(1, fn () => 42)); + assertType('42|Hypervel\Types\Relations\Car', $user->car()->findOr(1, callback: fn () => 42)); assertType('Hypervel\Types\Relations\Car|null', $user->car()->first()); + assertType('42|Hypervel\Types\Relations\Car', $user->car()->firstOr(fn () => 42)); + assertType('42|Hypervel\Types\Relations\Car', $user->car()->firstOr(callback: fn () => 42)); + assertType('Hypervel\Support\LazyCollection', $user->car()->lazy()); + assertType('Hypervel\Support\LazyCollection', $user->car()->lazyById()); + assertType('Hypervel\Support\LazyCollection', $user->car()->cursor()); assertType('Hypervel\Database\Eloquent\Relations\HasManyThrough', $user->parts()); assertType('Hypervel\Database\Eloquent\Collection', $user->parts()->getResults()); + assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $user->firstPart()); assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $post->user()); assertType('Hypervel\Types\Relations\User|null', $post->user()->getResults()); @@ -77,6 +106,7 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Types\Relations\User', $post->user()->create()); assertType('Hypervel\Types\Relations\Post', $post->user()->associate(new User())); assertType('Hypervel\Types\Relations\Post', $post->user()->dissociate()); + assertType('Hypervel\Types\Relations\Post', $post->user()->disassociate()); assertType('Hypervel\Types\Relations\Post', $post->user()->getChild()); assertType('Hypervel\Database\Eloquent\Relations\MorphOne', $post->image()); @@ -85,6 +115,7 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $post->comments()); assertType('Hypervel\Database\Eloquent\Collection', $post->comments()->getResults()); + assertType('Hypervel\Database\Eloquent\Relations\MorphOne', $post->latestComment()); assertType('Hypervel\Database\Eloquent\Relations\MorphTo', $comment->commentable()); assertType('Hypervel\Database\Eloquent\Model|null', $comment->commentable()->getResults()); @@ -119,6 +150,15 @@ public function posts(): HasMany return $hasMany; } + /** @return HasOne */ + public function latestPost(): HasOne + { + $post = $this->posts()->one(); + assertType('Hypervel\Database\Eloquent\Relations\HasOne', $post); + + return $post; + } + /** @return BelongsToMany */ public function roles(): BelongsToMany { @@ -146,17 +186,76 @@ public function car(): HasOneThrough $hasOneThrough = $this->hasOneThrough(Car::class, Mechanic::class); assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $hasOneThrough); + $through = $this->through('mechanic'); + assertType( + 'Hypervel\Database\Eloquent\PendingHasThroughRelationship', + $through, + ); + assertType( + 'Hypervel\Database\Eloquent\Relations\HasManyThrough|Hypervel\Database\Eloquent\Relations\HasOneThrough', + $through->has('car'), + ); + + $through = $this->through($this->mechanic()); + assertType( + 'Hypervel\Database\Eloquent\PendingHasThroughRelationship>', + $through, + ); + assertType( + 'Hypervel\Database\Eloquent\Relations\HasOneThrough', + $through->has(function ($mechanic) { + assertType('Hypervel\Types\Relations\Mechanic', $mechanic); + + return $mechanic->car(); + }), + ); + return $hasOneThrough; } + /** @return HasManyThrough */ + public function cars(): HasManyThrough + { + $through = $this->through($this->mechanics()); + assertType( + 'Hypervel\Database\Eloquent\PendingHasThroughRelationship>', + $through, + ); + $hasManyThrough = $through->has(function ($mechanic) { + assertType('Hypervel\Types\Relations\Mechanic', $mechanic); + + return $mechanic->car(); + }); + assertType( + 'Hypervel\Database\Eloquent\Relations\HasManyThrough', + $hasManyThrough, + ); + + return $hasManyThrough; + } + /** @return HasManyThrough */ public function parts(): HasManyThrough { $hasManyThrough = $this->hasManyThrough(Part::class, Mechanic::class); assertType('Hypervel\Database\Eloquent\Relations\HasManyThrough', $hasManyThrough); + assertType( + 'Hypervel\Database\Eloquent\Relations\HasManyThrough', + $this->through($this->mechanic())->has(fn ($mechanic) => $mechanic->parts()), + ); + return $hasManyThrough; } + + /** @return HasOneThrough */ + public function firstPart(): HasOneThrough + { + $part = $this->parts()->one(); + assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $part); + + return $part; + } } class Post extends Model @@ -188,6 +287,15 @@ public function comments(): MorphMany return $morphMany; } + /** @return MorphOne */ + public function latestComment(): MorphOne + { + $comment = $this->comments()->one(); + assertType('Hypervel\Database\Eloquent\Relations\MorphOne', $comment); + + return $comment; + } + /** @return MorphToMany */ public function tags(): MorphToMany { diff --git a/types/Database/Query/Builder.php b/types/Database/Query/Builder.php index eca53f0ce..44a225a92 100644 --- a/types/Database/Query/Builder.php +++ b/types/Database/Query/Builder.php @@ -13,10 +13,10 @@ /** @param \Hypervel\Database\Eloquent\Builder $userQuery */ function test(Builder $query, EloquentBuilder $userQuery): void { - assertType('object|null', $query->first()); - assertType('object|null', $query->find(1)); - assertType('42|object', $query->findOr(1, fn () => 42)); - assertType('42|object', $query->findOr(1, callback: fn () => 42)); + assertType('stdClass|null', $query->first()); + assertType('stdClass|null', $query->find(1)); + assertType('42|stdClass', $query->findOr(1, fn () => 42)); + assertType('42|stdClass', $query->findOr(1, callback: fn () => 42)); assertType('Hypervel\Database\Query\Builder', $query->selectSub($userQuery, 'alias')); assertType('Hypervel\Database\Query\Builder', $query->fromSub($userQuery, 'alias')); assertType('Hypervel\Database\Query\Builder', $query->from($userQuery, 'alias')); @@ -36,31 +36,36 @@ function test(Builder $query, EloquentBuilder $userQuery): void assertType('Hypervel\Database\Query\Builder', $query->unionAll($userQuery)); assertType('int', $query->insertUsing([], $userQuery)); assertType('int', $query->insertOrIgnoreUsing([], $userQuery)); - assertType('Hypervel\Support\LazyCollection', $query->lazy()); - assertType('Hypervel\Support\LazyCollection', $query->lazyById()); - assertType('Hypervel\Support\LazyCollection', $query->lazyByIdDesc()); + assertType('Hypervel\Support\LazyCollection', $query->lazy()); + assertType('Hypervel\Support\LazyCollection', $query->lazyById()); + assertType('Hypervel\Support\LazyCollection', $query->lazyByIdDesc()); $query->chunk(1, function ($users, $page) { - assertType('Hypervel\Support\Collection', $users); + assertType('Hypervel\Support\Collection', $users); assertType('int', $page); }); $query->chunkById(1, function ($users, $page) { - assertType('Hypervel\Support\Collection', $users); + assertType('Hypervel\Support\Collection', $users); assertType('int', $page); }); $query->chunkMap(function ($users) { - assertType('object', $users); + assertType('stdClass', $users); }); $query->chunkByIdDesc(1, function ($users, $page) { - assertType('Hypervel\Support\Collection', $users); + assertType('Hypervel\Support\Collection', $users); assertType('int', $page); }); $query->each(function ($users, $page) { - assertType('object', $users); + assertType('stdClass', $users); assertType('int', $page); }); $query->eachById(function ($users, $page) { - assertType('object', $users); + assertType('stdClass', $users); assertType('int', $page); }); + assertType('Hypervel\Database\Query\Builder', $query->pipe(function () { + })); + assertType('Hypervel\Database\Query\Builder', $query->pipe(fn () => null)); + assertType('Hypervel\Database\Query\Builder', $query->pipe(fn ($query) => $query)); + assertType('5', $query->pipe(fn ($query) => 5)); } diff --git a/types/Pagination/Paginator.php b/types/Pagination/Paginator.php new file mode 100644 index 000000000..833c6b66f --- /dev/null +++ b/types/Pagination/Paginator.php @@ -0,0 +1,66 @@ + $paginator */ +$paginator = new Paginator($items, 1, 1); + +assertType('array', $paginator->items()); +assertType('Traversable', $paginator->getIterator()); + +$paginator->each(function ($post) { + assertType('Post', $post); +}); + +foreach ($paginator as $post) { + assertType('Post', $post); +} + +/** @var LengthAwarePaginator $lengthAwarePaginator */ +$lengthAwarePaginator = new LengthAwarePaginator($items, 1, 1); + +assertType('array', $lengthAwarePaginator->items()); +assertType('Traversable', $lengthAwarePaginator->getIterator()); + +$lengthAwarePaginator->each(function ($post) { + assertType('Post', $post); +}); + +foreach ($lengthAwarePaginator as $post) { + assertType('Post', $post); +} + +/** @var CursorPaginator $cursorPaginator */ +$cursorPaginator = new CursorPaginator($items, 1); + +assertType('array', $cursorPaginator->items()); +assertType('ArrayIterator', $cursorPaginator->getIterator()); + +$cursorPaginator->each(function ($post) { + assertType('Post', $post); +}); + +foreach ($cursorPaginator as $post) { + assertType('Post', $post); +} + +$throughPaginator = clone $cursorPaginator; +$throughPaginator->through(function ($post, $key): array { + assertType('int', $key); + assertType('Post', $post); + + return [ + 'id' => $key, + 'post' => $post, + ]; +}); + +assertType('Hypervel\Pagination\CursorPaginator', $throughPaginator);